using System; using System.Collections.Generic; using System.Linq; using System.Threading; using OpenNest.Engine.Jobs; namespace OpenNest.Engine.Qwen38FlashNext.Engine; using Math = System.Math; internal sealed record SheetAttempt(SheetPacker Packer, int StockIndex); /// /// Whole-job decision layer: which stock the next sheet uses, the order parts are /// demanded in, when a sheet is finished, and when the job stops. Every placement /// inside a sheet comes from ; nothing here delegates to a /// built-in engine, nester, filler, or runner. /// internal sealed class JobSolver { private NestJobResultBuilder _builder = null!; private readonly NestJob _job; private readonly PartPreparation _prep; private int _sheetCount; public JobSolver(NestJob job, PartPreparation prep) { _job = job; _prep = prep; } internal static bool Diagnostics { get; set; } private static bool _diag => Diagnostics; private void Diag(string message) { if (_diag) Console.Error.WriteLine( $"[qwen] sheets={_sheetCount} placed={_job.Parts.Sum(p => _builder.Placed(p.Id))} " + $"mem={GC.GetTotalMemory(false) / 1048576}MB gc0={GC.CollectionCount(0)} " + $"gc2={GC.CollectionCount(2)} {message}" ); } public NestJobResult Solve(IProgress? progress, CancellationToken token) { _builder = new NestJobResultBuilder(_job, progress); var reason = NestJobStopReason.Completed; while (true) { token.ThrowIfCancellationRequested(); var outstanding = OutstandingDemands(); if (outstanding.Count == 0) { reason = _builder.IsComplete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound; break; } if (_job.Options.MaxPlates is int cap && _sheetCount >= cap) { reason = NestJobStopReason.PlateLimitReached; break; } var attempt = BestNextSheet(outstanding, progress, token); Diag($"nextSheet -> {(attempt == null ? "none" : $"stock {_job.Plates[attempt.StockIndex].Id} placed {attempt.Packer.Placed.Count}")}"); if (attempt == null) { reason = AnyStockAvailable() ? NestJobStopReason.NoPlacementFound : NestJobStopReason.StockExhausted; break; } CommitSheet(attempt.Packer); } return _builder.Build(reason); } private List OutstandingDemands() { var demands = new List(); foreach (var model in _prep.Models) if ((model.Quantity - _builder.Placed(model.Id)) > 0) demands.Add(model); // This engine's own ordering: priority first, then the tallest-then-largest // part first (a part's thinnest orientation extent), then id for determinism. demands.Sort( (a, b) => { var byPriority = a.Priority.CompareTo(b.Priority); if (byPriority != 0) return byPriority; if (DemandOrderMode == 1) { var byArea = b.Area.CompareTo(a.Area); if (byArea != 0) return byArea; } else if (DemandOrderMode == 2) { // Biggest footprint first (worst-case largest extent, descending). var byMaxSpan = MaximumMaxSpan(b).CompareTo(MaximumMaxSpan(a)); if (byMaxSpan != 0) return -byMaxSpan; var byArea2 = b.Area.CompareTo(a.Area); if (byArea2 != 0) return byArea2; } else { var bySpan = MinimumMaxSpan(b).CompareTo(MinimumMaxSpan(a)); if (bySpan != 0) return bySpan; var byArea = b.Area.CompareTo(a.Area); if (byArea != 0) return byArea; } return string.CompareOrdinal(a.Id, b.Id); } ); return demands; } private double MinimumMaxSpan(PartModel model) { if (!_minimumSpan.TryGetValue(model.Id, out var span)) { span = double.MaxValue; foreach (var angle in PartPreparation.CandidateAngles(model)) { var orientation = _prep.Oriented(model, angle, 0); var worst = Math.Max(orientation.Width, orientation.Height); if (worst < span) span = worst; } _minimumSpan[model.Id] = span; } return span; } private readonly Dictionary _minimumSpan = new(StringComparer.Ordinal); private double MaximumMaxSpan(PartModel model) { if (!_maximumSpan.TryGetValue(model.Id, out var span)) { span = 0; foreach (var angle in PartPreparation.CandidateAngles(model)) { var orientation = _prep.Oriented(model, angle, 0); var worst = Math.Max(orientation.Width, orientation.Height); if (worst > span) span = worst; } _maximumSpan[model.Id] = span; } return span; } private readonly Dictionary _maximumSpan = new(StringComparer.Ordinal); /// /// Packs every available stock size independently and commits the best trial: /// most instances first, then highest priority coverage, then the smallest sheet /// area (the cost function the benchmark scores), then input order. /// private SheetAttempt? BestNextSheet( List outstanding, IProgress? progress, CancellationToken token ) { SheetAttempt? best = null; TrialScore bestScore = default; for (var index = 0; index < _job.Plates.Count; index++) { var stock = _job.Plates[index]; if (stock.Quantity is int quantity && _builder.SheetsUsed(stock) >= quantity) continue; token.ThrowIfCancellationRequested(); progress?.Report( new NestJobProgress( NestJobStage.EvaluatingCandidate, stock.Id, _sheetCount, _sheetCount, _job.Parts.Sum(p => _builder.Placed(p.Id)) ) ); var packer = SheetPacker.Create(stock, _prep, index); FillSheet(packer, outstanding, token); if (packer.Placed.Count == 0) continue; var score = ScoreTrial(packer); bool Better(TrialScore s) { if (CostFirstScoring) { // Benchmark cost is total plate AREA, so prefer the trial that // delivers the cheapest material per unit of part area placed; // priority coverage still outranks, and count breaks cost ties. if (best == null) return true; if (s.priorityHits != bestScore.priorityHits) return s.priorityHits > bestScore.priorityHits; if (Math.Abs(s.costPerArea - bestScore.costPerArea) > 1e-9) return s.costPerArea < bestScore.costPerArea; if (s.count != bestScore.count) return s.count > bestScore.count; return s.area < bestScore.area; } return best == null || s.count > bestScore.count || (s.count == bestScore.count && s.priorityHits > bestScore.priorityHits) || ( s.count == bestScore.count && s.priorityHits == bestScore.priorityHits && s.area < bestScore.area ); } if (Better(score)) { best = new SheetAttempt(packer, index); bestScore = score; } } return best; } /// /// This engine's fill policy for one sheet: walk the demand order and drain each /// requirement greedily; a requirement that cannot place any more instances is /// skipped (never aborts the sheet) and retried on the next sheet. Consumes a /// local copy of demand - losing this trial must not change job state. /// private void FillSheet(SheetPacker packer, List outstanding, CancellationToken token) { var available = new Dictionary(StringComparer.Ordinal); foreach (var model in outstanding) available[model.Id] = (model.Quantity - _builder.Placed(model.Id)); // One drain pass per requirement, in demand order. Gap-filling retries are // deliberately NOT an unbounded loop: a sheet's failed-insert scans get more // expensive as it fills, so an unbounded retry loop blows the benchmark's // 5-minute wall (observed 2-3x on a 69-drawing job). Pass two runs only with // the explicit retry budget below. foreach (var model in outstanding) { if (available[model.Id] <= 0) continue; if (!packer.CanEverFit(model)) continue; while (available[model.Id] > 0 && !packer.IsFull) { token.ThrowIfCancellationRequested(); if (!packer.TryInsert(model, out _)) break; available[model.Id]--; } } // Count failed insertion sweeps, independent of machine speed and load. // Successful insertions are bounded by the outstanding quantities. var retries = GapFillFailedInsertBudget; var retryAgain = true; while (retryAgain && retries > 0) { retryAgain = false; foreach (var model in outstanding) { if (available[model.Id] <= 0 || packer.IsFull) continue; if (retries <= 0) break; while (available[model.Id] > 0) { token.ThrowIfCancellationRequested(); if (!packer.TryInsert(model, out _)) { retries--; break; } available[model.Id]--; retryAgain = true; } } } } /// /// Demand ordering within the priority sort: 1 = largest material area first /// (measured best: big parts establish the sheet skeleton, small ones then fill /// the seams; 12% lower job cost than span-first on a real production job), 2 = largest footprint /// first, 0 = smallest worst-case extent first (original). /// internal static int DemandOrderMode { get; set; } = 1; /// Maximum failed insertion sweeps per sheet gap-fill pass. internal static int GapFillFailedInsertBudget { get; set; } = 8; /// Trial-sheet metrics; costPerArea = net sheet area / material area placed. private readonly record struct TrialScore( int count, int priorityHits, double area, double costPerArea ); /// /// Greedy trial-comparison mode. Cost-first optimizes the benchmark's cost /// function (total plate area); count-first is the conservative fill policy. /// Internal setting permits controlled A/B tests. /// internal static bool CostFirstScoring { get; set; } = true; private TrialScore ScoreTrial(SheetPacker packer) { var count = packer.Placed.Count; var bestPriority = int.MaxValue; foreach (var placed in packer.Placed) if (placed.Model.Priority < bestPriority) bestPriority = placed.Model.Priority; var priorityHits = packer.Placed.Count(p => p.Model.Priority == bestPriority); var area = NestJobCost.NetSheetArea(_job, new NestJobPlateResult(0, packer.Stock, packer.Placed.Select(p => new NestJobPlacement(p.Model.Id, 0, p.X, p.Y, p.Orientation.Angle)))); var placedArea = 0.0; foreach (var placed in packer.Placed) placedArea += placed.Model.Area; var costPerArea = placedArea > 1e-9 ? area / placedArea : double.MaxValue; return new TrialScore(count, priorityHits, area, costPerArea); } private void CommitSheet(SheetPacker packer) { _builder.AddSheet(packer.Stock, packer.Placed.Select(p => (p.Model.Id, p.X, p.Y, p.Orientation.Angle))); _sheetCount++; } private bool AnyStockAvailable() { foreach (var stock in _job.Plates) if (stock.Quantity is null || _builder.SheetsUsed(stock) < stock.Quantity.Value) return true; return false; } }