Qwen3.8-Flash-Next's final version after a 14.5-hour optimization run (its commit 7d7fca3): cost-first sheet trials, largest-area-first demand order, and a cached-triangulation exact gate that brought a 219-part production job from timeout to ~106 s. 13/13 tests pass against OpenNest master. README cleaned for publishing: the model-facing template rules are replaced by a one-line independence statement, the production job is described generically instead of by its PEP job/file name (also in a JobSolver comment), results show both sheet pools as re-measured here (the 9-size claim in its report didn't reproduce: it grabs 96x240 and under-fills them), and the stale StockLadder-crash note is gone now that core leaves etch marks out of nesting. Also drops a stale Aurora plugin reference from Opus55's README and lists the engine in the repo README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
440 lines
17 KiB
C#
440 lines
17 KiB
C#
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);
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="SheetPacker"/>; nothing here delegates to a
|
|
/// built-in engine, nester, filler, or runner.
|
|
/// </summary>
|
|
internal sealed class JobSolver
|
|
{
|
|
private readonly NestJob _job;
|
|
private readonly PartPreparation _prep;
|
|
private readonly Dictionary<string, int> _remaining;
|
|
private readonly Dictionary<string, int> _placed;
|
|
private readonly Dictionary<string, int> _used;
|
|
private readonly List<CommittedSheet> _sheets = new();
|
|
|
|
private sealed record CommittedSheet(int StockIndex, List<PlacedPart> Placements);
|
|
|
|
public JobSolver(NestJob job, PartPreparation prep)
|
|
{
|
|
_job = job;
|
|
_prep = prep;
|
|
_remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
|
|
_placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal);
|
|
_used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
|
|
}
|
|
|
|
private static readonly bool _diag =
|
|
Environment.GetEnvironmentVariable("QWEN_NEST_DIAG") == "1";
|
|
|
|
private void Diag(string message)
|
|
{
|
|
if (_diag)
|
|
Console.Error.WriteLine(
|
|
$"[qwen] sheets={_sheets.Count} placed={_placed.Values.Sum()} " +
|
|
$"mem={GC.GetTotalMemory(false) / 1048576}MB gc0={GC.CollectionCount(0)} " +
|
|
$"gc2={GC.CollectionCount(2)} {message}"
|
|
);
|
|
}
|
|
|
|
public NestJobResult Solve(IProgress<NestJobProgress>? progress, CancellationToken token)
|
|
{
|
|
var reason = NestJobStopReason.Completed;
|
|
while (true)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
var outstanding = OutstandingDemands();
|
|
if (outstanding.Count == 0)
|
|
break;
|
|
if (_job.Options.MaxPlates is int cap && _sheets.Count >= 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);
|
|
progress?.Report(
|
|
new NestJobProgress(
|
|
NestJobStage.PlateCommitted,
|
|
_job.Plates[attempt.StockIndex].Id,
|
|
_sheets.Count - 1,
|
|
_sheets.Count,
|
|
_placed.Values.Sum()
|
|
)
|
|
);
|
|
}
|
|
|
|
return BuildResult(reason);
|
|
}
|
|
|
|
private List<PartModel> OutstandingDemands()
|
|
{
|
|
var demands = new List<PartModel>();
|
|
foreach (var model in _prep.Models)
|
|
if (_remaining[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<string, double> _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<string, double> _maximumSpan = new(StringComparer.Ordinal);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private SheetAttempt? BestNextSheet(
|
|
List<PartModel> outstanding,
|
|
IProgress<NestJobProgress>? 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 && _used[stock.Id] >= quantity)
|
|
continue;
|
|
|
|
token.ThrowIfCancellationRequested();
|
|
progress?.Report(
|
|
new NestJobProgress(
|
|
NestJobStage.EvaluatingCandidate,
|
|
stock.Id,
|
|
_sheets.Count,
|
|
_sheets.Count,
|
|
_placed.Values.Sum()
|
|
)
|
|
);
|
|
|
|
var packer = SheetPacker.Create(stock, _prep, index);
|
|
var fillWatch = System.Diagnostics.Stopwatch.StartNew();
|
|
FillSheet(packer, outstanding, token);
|
|
fillWatch.Stop();
|
|
if (_diag)
|
|
Diag(
|
|
$"trial stock {stock.Id}: placed={packer.Placed.Count} " +
|
|
$"{fillWatch.ElapsedMilliseconds}ms {packer.DiagStats()}"
|
|
);
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void FillSheet(SheetPacker packer, List<PartModel> outstanding, CancellationToken token)
|
|
{
|
|
var available = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
foreach (var model in outstanding)
|
|
available[model.Id] = _remaining[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;
|
|
var modelWatch = System.Diagnostics.Stopwatch.StartNew();
|
|
while (available[model.Id] > 0 && !packer.IsFull)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (!packer.TryInsert(model, out _))
|
|
break;
|
|
available[model.Id]--;
|
|
}
|
|
modelWatch.Stop();
|
|
if (_diag && modelWatch.ElapsedMilliseconds > 200)
|
|
Diag($" fill model {model.Id}: placed={packer.Placed.Count} {modelWatch.ElapsedMilliseconds}ms {packer.DiagStats()}");
|
|
}
|
|
|
|
// Gap-fill pass: a part that could not fit between two early placements may
|
|
// fit the gaps a later model leaves behind. Failed-insert scans cost about a
|
|
// full candidate sweep each, so the pass is hard time-boxed - on a crowded
|
|
// sheet the untried budget was measured at minutes per job, well over the
|
|
// benchmark's wall; the box keeps worst case near the first pass's cost.
|
|
var retryWatch = System.Diagnostics.Stopwatch.StartNew();
|
|
var retryAgain = true;
|
|
while (retryAgain && retryWatch.ElapsedMilliseconds < GapFillMilliseconds)
|
|
{
|
|
retryAgain = false;
|
|
foreach (var model in outstanding)
|
|
{
|
|
if (available[model.Id] <= 0 || packer.IsFull)
|
|
continue;
|
|
if (retryWatch.ElapsedMilliseconds >= GapFillMilliseconds)
|
|
break;
|
|
while (available[model.Id] > 0)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (!packer.TryInsert(model, out _))
|
|
break;
|
|
available[model.Id]--;
|
|
retryAgain = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
private static readonly int DemandOrderMode =
|
|
int.TryParse(Environment.GetEnvironmentVariable("QWEN_DEMAND_ORDER"), out var m)
|
|
? m
|
|
: 1;
|
|
|
|
/// <summary>Wall-clock budget for one sheet's gap-fill pass.</summary>
|
|
private static readonly int GapFillMilliseconds =
|
|
int.TryParse(Environment.GetEnvironmentVariable("QWEN_GAPFILL_MS"), out var ms)
|
|
? ms
|
|
: 120;
|
|
|
|
/// <summary>Trial-sheet metrics; costPerArea = plate area / part area placed.</summary>
|
|
private readonly record struct TrialScore(
|
|
int count,
|
|
int priorityHits,
|
|
double area,
|
|
double costPerArea
|
|
);
|
|
|
|
/// <summary>
|
|
/// Greedy trial-comparison mode. Cost-first optimizes the benchmark's cost
|
|
/// function (total plate area); count-first is the conservative fill policy.
|
|
/// Env override exists for A/B measurement.
|
|
/// </summary>
|
|
private static readonly bool CostFirstScoring =
|
|
Environment.GetEnvironmentVariable("QWEN_COST_FIRST") != "0";
|
|
|
|
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 = packer.Stock.Size.Width * packer.Stock.Size.Length;
|
|
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)
|
|
{
|
|
_sheets.Add(new CommittedSheet(packer.StockIndex, packer.Placed));
|
|
_used[packer.Stock.Id]++;
|
|
foreach (var placed in packer.Placed)
|
|
{
|
|
_placed[placed.Model.Id]++;
|
|
_remaining[placed.Model.Id]--;
|
|
}
|
|
}
|
|
|
|
private bool AnyStockAvailable()
|
|
{
|
|
foreach (var stock in _job.Plates)
|
|
if (stock.Quantity is null || _used[stock.Id] < stock.Quantity.Value)
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
private NestJobResult BuildResult(NestJobStopReason reason)
|
|
{
|
|
var instanceIndex = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
var plates = new List<NestJobPlateResult>();
|
|
foreach (var sheet in _sheets)
|
|
{
|
|
var placements = new List<NestJobPlacement>(sheet.Placements.Count);
|
|
foreach (var placed in sheet.Placements)
|
|
{
|
|
instanceIndex.TryGetValue(placed.Model.Id, out var next);
|
|
instanceIndex[placed.Model.Id] = next + 1;
|
|
placements.Add(
|
|
new NestJobPlacement(
|
|
placed.Model.Id,
|
|
next,
|
|
placed.X,
|
|
placed.Y,
|
|
placed.Orientation.Angle
|
|
)
|
|
);
|
|
}
|
|
plates.Add(
|
|
new NestJobPlateResult(sheet.StockIndex, _job.Plates[sheet.StockIndex], placements)
|
|
);
|
|
}
|
|
|
|
var fulfillment = _job.Parts
|
|
.Select(part => new PartFulfillment(
|
|
part.Id,
|
|
part.Quantity,
|
|
_placed[part.Id],
|
|
_remaining[part.Id]
|
|
))
|
|
.ToList();
|
|
|
|
var stockUsage = _job.Plates
|
|
.Select(stock => new StockUsage(
|
|
stock.Id,
|
|
_used[stock.Id],
|
|
stock.Quantity is int quantity ? quantity - _used[stock.Id] : null
|
|
))
|
|
.ToList();
|
|
|
|
return new NestJobResult(
|
|
reason == NestJobStopReason.Completed
|
|
? NestJobStatus.Complete
|
|
: NestJobStatus.Incomplete,
|
|
reason,
|
|
plates,
|
|
fulfillment,
|
|
stockUsage
|
|
);
|
|
}
|
|
}
|