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;
}
}