The gap-fill pass stopped on a 120 ms stopwatch and QWEN_* environment variables switched strategies, so the same job could nest differently with machine load or environment. Gap fill now stops after eight failed insertion sweeps, and the switches are internal properties with the old defaults. Part reading, work area, rotation angles, scoring and result assembly now use the host APIs; its collision gate is unchanged. Synthetic benchmark (5 jobs, salvage 0.5): all valid, cost 7660.01 -> 7572.05, time 839 -> 553 ms. Not yet calibrated on production-size jobs. Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
356 lines
13 KiB
C#
356 lines
13 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 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<NestJobProgress>? 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<PartModel> OutstandingDemands()
|
|
{
|
|
var demands = new List<PartModel>();
|
|
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<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 && _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;
|
|
}
|
|
|
|
/// <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] = (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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
internal static int DemandOrderMode { get; set; } = 1;
|
|
|
|
/// <summary>Maximum failed insertion sweeps per sheet gap-fill pass.</summary>
|
|
internal static int GapFillFailedInsertBudget { get; set; } = 8;
|
|
|
|
/// <summary>Trial-sheet metrics; costPerArea = net sheet area / material 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.
|
|
/// Internal setting permits controlled A/B tests.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
|
|
}
|