using System; using System.Threading; using OpenNest.Engine.Jobs; namespace OpenNest.Engine.Opus55; /// /// Frontier-advance NFP packer with look-ahead stock selection. /// /// Per sheet, 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. /// public sealed class Opus55NestingEngine : INestingEngine { /// /// Extra clearance beyond the stock's part spacing, in job units. Footprints already contain /// the true outline grown by half the clearance (chord tolerance is added separately), so this /// only has to cover the validator's side: NestValidator flattens each perimeter within /// 0.001 of the true arc (OutlineTolerance), so two true arcs can read up to 0.002 closer /// than they are. The remaining 0.001 absorbs the 1e-4 Clipper grid on both sides (footprint /// offset, Minkowski union, free-region difference, the validator's own offset); the /// validator's round-join chord error (0.0001) only ever makes it more lenient. /// internal const double ClearanceMargin = 0.003; /// Strategy variants, tried in order: (front direction, area exponent beta). 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), }; /// /// Deterministic work budget, in free-region subtractions, after which no further variant /// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock. /// internal long WorkBudget { get; init; } = 1_500_000; public NestJobResult Solve( NestJob job, IProgress? progress = null, CancellationToken token = default ) { ArgumentNullException.ThrowIfNull(job); var types = PartCatalog.Build(job); var solver = new Solver(job, types, progress, token); // Demand that no offered stock can hold in any allowed orientation 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 => FrontierPacker.Fits(o, FrontierPacker.WorkArea(stock))) ); demand[type.Index] = placeable ? 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); } /// Shared state for one solve: job, catalog, NFP caches, effort meter. private sealed class Solver( NestJob job, IReadOnlyList types, IProgress? progress, CancellationToken token ) { private const int MaxTail = 3; private readonly Dictionary caches = new(); public WorkCounter Work { get; } = new(); private double Penalty => job.Plates.Count == 0 ? 0 : job.Plates.Max(SheetEconomics.SheetArea); public Plan Plan(int[] demand, PackAxis axis, double beta) { var run = Decode(demand, axis, beta, new Dictionary(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); } /// /// 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. /// 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 => SheetEconomics.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) + ClearanceMargin; if (!caches.TryGetValue(clearance, out var cache)) caches[clearance] = cache = new NoFitCache(clearance); return cache; } /// /// Greedy sheet-by-sheet decode. seeds finite-stock /// accounting, bounds the sheets this run may add, and /// , when set, forces the stock of the first sheet. /// private Run Decode( int[] demand, PackAxis axis, double beta, IReadOnlyDictionary 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(); 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), stock, axis, beta, Work); var fill = packer.Fill(remaining, token); if (fill.Parts.Count > 0) trials.Add((fill, SheetEconomics.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 Sheets, double Net, NestJobStopReason Reason); private static NestJobResult BuildResult( NestJob job, IReadOnlyList types, Plan plan, IProgress? progress ) { var placed = new int[types.Count]; var plates = new List(plan.Sheets.Count); var committedParts = 0; foreach (var sheet in plan.Sheets) { var placements = sheet.Parts.Select(p => { var type = types[p.Orientation.TypeIndex]; return new NestJobPlacement(type.Part.Id, placed[type.Index]++, p.X, p.Y, p.Orientation.Rotation); }); plates.Add(new NestJobPlateResult(plates.Count, sheet.Stock, placements.ToList())); committedParts += sheet.Parts.Count; progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, sheet.Stock.Id, plates.Count - 1, plates.Count, committedParts)); } var fulfillment = types.Select(t => new PartFulfillment(t.Part.Id, t.Part.Quantity, placed[t.Index], t.Part.Quantity - placed[t.Index])); var usage = job.Plates.Select(stock => { var count = plan.Sheets.Count(s => ReferenceEquals(s.Stock, stock)); return new StockUsage(stock.Id, count, stock.Quantity - count); }); var status = plan.Unplaced == 0 ? NestJobStatus.Complete : NestJobStatus.Incomplete; return new NestJobResult(status, plan.Reason, plates, fulfillment.ToList(), usage.ToList()); } private sealed record Plan(IReadOnlyList 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; } } } /// Deterministic effort meter shared by all packers in one solve. internal sealed class WorkCounter { private long value; public long Value => Interlocked.Read(ref value); public void Add(long amount) => Interlocked.Add(ref value, amount); }