fix(qwen38flashnext): make layouts deterministic and use host services
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>
This commit is contained in:
@@ -18,32 +18,25 @@ internal sealed record SheetAttempt(SheetPacker Packer, int StockIndex);
|
||||
/// </summary>
|
||||
internal sealed class JobSolver
|
||||
{
|
||||
private NestJobResultBuilder _builder = null!;
|
||||
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);
|
||||
private int _sheetCount;
|
||||
|
||||
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";
|
||||
internal static bool Diagnostics { get; set; }
|
||||
private static bool _diag => Diagnostics;
|
||||
|
||||
private void Diag(string message)
|
||||
{
|
||||
if (_diag)
|
||||
Console.Error.WriteLine(
|
||||
$"[qwen] sheets={_sheets.Count} placed={_placed.Values.Sum()} " +
|
||||
$"[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}"
|
||||
);
|
||||
@@ -51,14 +44,18 @@ internal sealed class JobSolver
|
||||
|
||||
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 && _sheets.Count >= cap)
|
||||
}
|
||||
if (_job.Options.MaxPlates is int cap && _sheetCount >= cap)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
@@ -75,25 +72,16 @@ internal sealed class JobSolver
|
||||
}
|
||||
|
||||
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);
|
||||
return _builder.Build(reason);
|
||||
}
|
||||
|
||||
private List<PartModel> OutstandingDemands()
|
||||
{
|
||||
var demands = new List<PartModel>();
|
||||
foreach (var model in _prep.Models)
|
||||
if (_remaining[model.Id] > 0)
|
||||
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.
|
||||
@@ -189,7 +177,7 @@ internal sealed class JobSolver
|
||||
for (var index = 0; index < _job.Plates.Count; index++)
|
||||
{
|
||||
var stock = _job.Plates[index];
|
||||
if (stock.Quantity is int quantity && _used[stock.Id] >= quantity)
|
||||
if (stock.Quantity is int quantity && _builder.SheetsUsed(stock) >= quantity)
|
||||
continue;
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -197,21 +185,14 @@ internal sealed class JobSolver
|
||||
new NestJobProgress(
|
||||
NestJobStage.EvaluatingCandidate,
|
||||
stock.Id,
|
||||
_sheets.Count,
|
||||
_sheets.Count,
|
||||
_placed.Values.Sum()
|
||||
_sheetCount,
|
||||
_sheetCount,
|
||||
_job.Parts.Sum(p => _builder.Placed(p.Id))
|
||||
)
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
@@ -261,7 +242,7 @@ internal sealed class JobSolver
|
||||
{
|
||||
var available = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var model in outstanding)
|
||||
available[model.Id] = _remaining[model.Id];
|
||||
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
|
||||
@@ -274,7 +255,6 @@ internal sealed class JobSolver
|
||||
continue;
|
||||
if (!packer.CanEverFit(model))
|
||||
continue;
|
||||
var modelWatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
while (available[model.Id] > 0 && !packer.IsFull)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
@@ -282,32 +262,30 @@ internal sealed class JobSolver
|
||||
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();
|
||||
// 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 && retryWatch.ElapsedMilliseconds < GapFillMilliseconds)
|
||||
while (retryAgain && retries > 0)
|
||||
{
|
||||
retryAgain = false;
|
||||
foreach (var model in outstanding)
|
||||
{
|
||||
if (available[model.Id] <= 0 || packer.IsFull)
|
||||
continue;
|
||||
if (retryWatch.ElapsedMilliseconds >= GapFillMilliseconds)
|
||||
if (retries <= 0)
|
||||
break;
|
||||
while (available[model.Id] > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!packer.TryInsert(model, out _))
|
||||
{
|
||||
retries--;
|
||||
break;
|
||||
}
|
||||
available[model.Id]--;
|
||||
retryAgain = true;
|
||||
}
|
||||
@@ -321,18 +299,12 @@ internal sealed class JobSolver
|
||||
/// 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;
|
||||
internal static int DemandOrderMode { get; set; } = 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>Maximum failed insertion sweeps per sheet gap-fill pass.</summary>
|
||||
internal static int GapFillFailedInsertBudget { get; set; } = 8;
|
||||
|
||||
/// <summary>Trial-sheet metrics; costPerArea = plate area / part area placed.</summary>
|
||||
/// <summary>Trial-sheet metrics; costPerArea = net sheet area / material area placed.</summary>
|
||||
private readonly record struct TrialScore(
|
||||
int count,
|
||||
int priorityHits,
|
||||
@@ -343,10 +315,9 @@ internal sealed class JobSolver
|
||||
/// <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.
|
||||
/// Internal setting permits controlled A/B tests.
|
||||
/// </summary>
|
||||
private static readonly bool CostFirstScoring =
|
||||
Environment.GetEnvironmentVariable("QWEN_COST_FIRST") != "0";
|
||||
internal static bool CostFirstScoring { get; set; } = true;
|
||||
|
||||
private TrialScore ScoreTrial(SheetPacker packer)
|
||||
{
|
||||
@@ -356,7 +327,8 @@ internal sealed class JobSolver
|
||||
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 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;
|
||||
@@ -366,74 +338,18 @@ internal sealed class JobSolver
|
||||
|
||||
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]--;
|
||||
}
|
||||
_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 || _used[stock.Id] < stock.Quantity.Value)
|
||||
if (stock.Quantity is null || _builder.SheetsUsed(stock) < 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(plates.Count, _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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
@@ -53,10 +51,11 @@ internal sealed class PartModel
|
||||
|
||||
/// <summary>Material area (perimeter minus holes), from the analytic shapes.</summary>
|
||||
public double Area { get; }
|
||||
internal List<double>? Angles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Chord tolerance for the engine's internal collision polygons. Must stay FINER
|
||||
/// than the validator's OutlineTolerance (0.001): any chord cuts the cap off a
|
||||
/// than NestTolerances.ValidationOutline (0.001): any chord cuts the cap off a
|
||||
/// concave arc, and a coarser polygon cuts MORE - so a coarse flattening is a
|
||||
/// subset of the validator's material in notched regions and admits real spacing
|
||||
/// violations (observed on arc-heavy PEP parts at 0.02). Finer than the validator,
|
||||
@@ -70,47 +69,11 @@ internal sealed class PartModel
|
||||
/// </summary>
|
||||
public static PartModel? TryCreate(NestJobPart part)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entities = new List<Entity>();
|
||||
foreach (
|
||||
var entity in ConvertProgram.ToGeometry(
|
||||
DrawingJobMapper.ToProgram(part.Geometry)
|
||||
)
|
||||
)
|
||||
if (SpecialLayers.IsMaterial(entity.Layer))
|
||||
entities.Add(entity);
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
|
||||
var profile = new ShapeProfile(entities);
|
||||
if (profile.Perimeter == null)
|
||||
return null;
|
||||
profile.NormalizeWinding();
|
||||
|
||||
var area = Math.Abs(profile.Perimeter.Area());
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
area -= Math.Abs(cutout.Area());
|
||||
if (!double.IsFinite(area) || area <= Tolerance.Epsilon)
|
||||
return null;
|
||||
|
||||
return new PartModel(
|
||||
part.Id,
|
||||
part.Quantity,
|
||||
part.Priority,
|
||||
part.Rotation,
|
||||
profile,
|
||||
profile.Perimeter,
|
||||
new List<Shape>(profile.Cutouts),
|
||||
area
|
||||
);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Malformed snapshots are unplaceable, not fatal: report them unplaced so
|
||||
// the rest of the job still nests.
|
||||
return null;
|
||||
}
|
||||
var geometry = JobPartGeometry.TryRead(part.Geometry);
|
||||
if (geometry == null || geometry.MaterialArea <= Tolerance.Epsilon) return null;
|
||||
// Read normalizes winding before the collision and offset preparation below.
|
||||
return new PartModel(part.Id, part.Quantity, part.Priority, part.Rotation,
|
||||
geometry.Profile, geometry.Perimeter, geometry.Cutouts.ToList(), geometry.MaterialArea);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,84 +404,15 @@ internal sealed class PartPreparation
|
||||
/// </summary>
|
||||
public static List<double> CandidateAngles(PartModel model)
|
||||
{
|
||||
var angles = new List<double>();
|
||||
var policy = model.Rotation;
|
||||
if (policy.Kind == RotationPolicyKind.Automatic)
|
||||
{
|
||||
angles.Add(0);
|
||||
angles.Add(Math.PI / 2);
|
||||
angles.Add(Math.PI);
|
||||
angles.Add(3 * Math.PI / 2);
|
||||
try
|
||||
{
|
||||
var hull = ConvexHull.Compute(
|
||||
model
|
||||
.PerimeterShape
|
||||
.ToPolygonWithTolerance(PartModel.CollisionTolerance, circumscribe: true)
|
||||
.Vertices
|
||||
);
|
||||
var obb = RotatingCalipers.MinimumBoundingRectangle(hull);
|
||||
var normalized = OpenNest.Math.Angle.NormalizeRad(obb.Angle);
|
||||
if (normalized > 0.001 && normalized < Math.PI - 0.001)
|
||||
{
|
||||
angles.Add(normalized);
|
||||
angles.Add(OpenNest.Math.Angle.NormalizeRad(normalized + Math.PI));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A calipers failure only costs candidate angles, never correctness.
|
||||
}
|
||||
}
|
||||
else if (policy.Kind == RotationPolicyKind.Fixed)
|
||||
{
|
||||
angles.Add(policy.Start);
|
||||
if (policy.Allow180Equivalent)
|
||||
angles.Add(policy.Start + Math.PI);
|
||||
}
|
||||
else
|
||||
{
|
||||
// BoundedSweep: enumerate the exact step grid the policy allows.
|
||||
var count = (int)Math.Floor((policy.End - policy.Start) / policy.Step + 1e-9);
|
||||
if (count < 0)
|
||||
count = 0;
|
||||
if (count > 4000)
|
||||
count = 4000;
|
||||
for (var i = 0; i <= count; i++)
|
||||
{
|
||||
angles.Add(policy.Start + i * policy.Step);
|
||||
if (policy.Allow180Equivalent)
|
||||
angles.Add(policy.Start + i * policy.Step + Math.PI);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to [0, 2pi), deduplicate, preserve first-seen order (deterministic).
|
||||
var unique = new List<double>();
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var normalized = OpenNest.Math.Angle.NormalizeRad(angle);
|
||||
if (normalized < 0)
|
||||
normalized += 2 * Math.PI;
|
||||
var duplicate = false;
|
||||
foreach (var existing in unique)
|
||||
if (Math.Abs(SignedDelta(existing, normalized)) < 1e-9)
|
||||
{
|
||||
duplicate = true;
|
||||
break;
|
||||
}
|
||||
if (!duplicate)
|
||||
unique.Add(normalized);
|
||||
}
|
||||
return unique;
|
||||
if (model.Angles != null) return model.Angles;
|
||||
var angles = model.Rotation.Kind == RotationPolicyKind.Automatic
|
||||
? RotationCandidates.ForShape(model.Rotation, model.PerimeterShape)
|
||||
: model.Rotation.EnumerateAngles(maxSamples: 4000);
|
||||
// Perimeter symmetry does not establish symmetry of the cutouts.
|
||||
return model.Angles = (model.CutoutShapes.Count == 0
|
||||
? RotationCandidates.DistinctOutlines(model.PerimeterShape, angles)
|
||||
: angles).ToList();
|
||||
}
|
||||
|
||||
private static double SignedDelta(double a, double b)
|
||||
{
|
||||
var delta = (a - b) % (2 * Math.PI);
|
||||
if (delta > Math.PI)
|
||||
delta -= 2 * Math.PI;
|
||||
if (delta < -Math.PI)
|
||||
delta += 2 * Math.PI;
|
||||
return delta;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -115,12 +115,11 @@ internal sealed class SheetPacker
|
||||
StockIndex = stockIndex;
|
||||
Preparation = prep;
|
||||
Spacing = stock.PartSpacing;
|
||||
var left = stock.Quadrant is 1 or 4 ? 0.0 : -stock.Size.Length;
|
||||
var bottom = stock.Quadrant is 1 or 2 ? 0.0 : -stock.Size.Width;
|
||||
_workLeft = left + stock.EdgeSpacing.Left;
|
||||
_workBottom = bottom + stock.EdgeSpacing.Bottom;
|
||||
_workRight = left + stock.Size.Length - stock.EdgeSpacing.Right;
|
||||
_workTop = bottom + stock.Size.Width - stock.EdgeSpacing.Top;
|
||||
var work = stock.WorkArea;
|
||||
_workLeft = work.Left;
|
||||
_workBottom = work.Bottom;
|
||||
_workRight = work.Right;
|
||||
_workTop = work.Top;
|
||||
WorkWidth = _workRight - _workLeft;
|
||||
WorkHeight = _workTop - _workBottom;
|
||||
|
||||
@@ -159,7 +158,7 @@ internal sealed class SheetPacker
|
||||
foreach (var angle in PartPreparation.CandidateAngles(model))
|
||||
{
|
||||
var orientation = Preparation.Oriented(model, angle, 0);
|
||||
if (orientation.Width <= WorkWidth + 1e-9 && orientation.Height <= WorkHeight + 1e-9)
|
||||
if (Stock.Fits(orientation.Width, orientation.Height))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -647,8 +646,7 @@ internal sealed class SheetPacker
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly bool VerifyFastClear =
|
||||
Environment.GetEnvironmentVariable("QWEN_VERIFY_FASTCLEAR") == "1";
|
||||
internal static bool VerifyFastClear { get; set; }
|
||||
|
||||
internal long DiagFastClearMismatch;
|
||||
internal long DiagTriMismatch;
|
||||
|
||||
@@ -7,8 +7,7 @@ or select over any built-in engine, nester, filler, or runner.
|
||||
## Algorithm
|
||||
|
||||
Bottom-left greedy insertion over convex No-Fit-Polygons, with an exact material-clearance
|
||||
gate, driven sheet by sheet by a greedy demand scheduler. All geometry math is the engine's
|
||||
own (`Engine/`); it calls no built-in nester, filler, or runner.
|
||||
gate, driven sheet by sheet by a greedy demand scheduler. Placement and collision preparation remain in `Engine/`; it calls no built-in nester, filler, or runner.
|
||||
|
||||
- **`PartPreparation`** rebuilds each snapshot into a closed contour topology (perimeter +
|
||||
cutouts; rapids/scribe marks dropped), flattens it circumscribed (the collision polygon
|
||||
@@ -46,9 +45,9 @@ own (`Engine/`); it calls no built-in nester, filler, or runner.
|
||||
- **`JobSolver`** walks demands in its own order (priority, then largest material area -
|
||||
big parts first lay down the sheet skeleton the small parts fill against; measured 12%
|
||||
lower job cost than smallest-extent-first on the production job below) and drains each greedily,
|
||||
then a time-boxed gap-fill pass. For the next sheet it trials *every* available stock
|
||||
size independently and commits the trial delivering the cheapest plate area per unit of
|
||||
part area placed (the benchmark's cost function), breaking ties by priority coverage,
|
||||
then a gap-fill pass capped at eight failed insertion sweeps. For the next sheet it trials *every* available stock
|
||||
size independently and commits the trial delivering the cheapest `NestJobCost.NetSheetArea` per unit of
|
||||
material area placed (the benchmark's cost function), breaking ties by priority coverage,
|
||||
instance count, then plate area; lost trials change no job state. The job stops on met
|
||||
demand, exhausted stock, no further placement, or the plate cap. Deterministic:
|
||||
identical input, identical layout.
|
||||
@@ -82,7 +81,7 @@ to ~110 s via the cached-triangulation exact gate and the fast shell prefilter.
|
||||
## Tests
|
||||
|
||||
`tests/` holds acceptance tests whose layouts are checked by the benchmark's own
|
||||
`NestValidator` (bounds, spacing, quantities, stock, rotation), plus NFP geometry tests and a
|
||||
`NestLayoutCheck` through the shared `Engine.Testing` kit (bounds, spacing, quantities, stock, rotation and accounting), plus NFP geometry tests and a
|
||||
rotated-concave spacing regression test.
|
||||
|
||||
```bash
|
||||
@@ -107,3 +106,23 @@ dotnet <OpenNest>/OpenNest.Benchmark/bin/Release/net8.0/OpenNest.Benchmark.dll <
|
||||
`<OpenNest>` is the OpenNest checkout root. Or build and deploy in one step with
|
||||
`./Build-Engines.ps1 -Engines Qwen38FlashNext`. The engine appears in reports as
|
||||
`Qwen38FlashNextNestingEngine`.
|
||||
|
||||
## Shared services and determinism
|
||||
|
||||
`JobPartGeometry.TryRead` provides normalized material topology. Stock bounds/fit and
|
||||
result accounting/progress use the shared stock API and `NestJobResultBuilder`.
|
||||
`ForShape` supplies Automatic rotations; `EnumerateAngles(maxSamples: 4000)` preserves
|
||||
the sweep effort cap. Solid parts use cached `DistinctOutlines`; holed parts retain every
|
||||
legal candidate because perimeter symmetry alone cannot establish cutout symmetry.
|
||||
CollisionTolerance remains 0.0005, below `NestTolerances.ValidationOutline`.
|
||||
|
||||
Gap fill now counts failed insertion sweeps (default eight), with successful insertions
|
||||
bounded by demand. Internal test settings replace every QWEN environment switch. No
|
||||
stopwatch affects placement or diagnostics. Only the host cancellation token limits wall
|
||||
time. The shared determinism contract compares repeated and fresh solves.
|
||||
|
||||
On the five synthetic salvage jobs, every layout remained valid and complete; total cost
|
||||
fell from 7660.01 to 7572.05, with no job worse. Aggregate measured solve time remained
|
||||
below one second. These small fixtures do not calibrate production-scale retry costs;
|
||||
the eight-sweep default bounds effort independently of hardware. See
|
||||
[PR 5 results](../MIGRATION-PR5.md); older production numbers above describe the old version.
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<ProjectReference Include="../../Engine.Testing/OpenNest.Engine.Testing.csproj" />
|
||||
<ProjectReference Include="../OpenNest.Engine.Qwen38FlashNext.csproj" />
|
||||
<!-- The benchmark's NestValidator is the arbiter the engine is scored by. -->
|
||||
<ProjectReference Include="$(OpenNestRoot)OpenNest.Benchmark/OpenNest.Benchmark.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using OpenNest.Engine.Testing;
|
||||
using static OpenNest.Engine.Testing.JobBuilder;
|
||||
using static OpenNest.Engine.Testing.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Benchmark;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
@@ -30,7 +32,7 @@ public class Qwen38FlashNextNestingEngineTests
|
||||
|
||||
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(12, result.Plates[0].Placements.Count);
|
||||
@@ -55,7 +57,7 @@ public class Qwen38FlashNextNestingEngineTests
|
||||
|
||||
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
@@ -76,7 +78,7 @@ public class Qwen38FlashNextNestingEngineTests
|
||||
|
||||
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
@@ -87,7 +89,7 @@ public class Qwen38FlashNextNestingEngineTests
|
||||
|
||||
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.True(result.Plates.Count > 1);
|
||||
}
|
||||
@@ -102,7 +104,7 @@ public class Qwen38FlashNextNestingEngineTests
|
||||
|
||||
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
|
||||
Assert.Equal(1, huge.Unplaced);
|
||||
}
|
||||
@@ -120,7 +122,7 @@ public class Qwen38FlashNextNestingEngineTests
|
||||
|
||||
var result = new Qwen38FlashNextNestingEngine().Solve(job);
|
||||
|
||||
AssertValid(job, result);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
@@ -138,65 +140,6 @@ public class Qwen38FlashNextNestingEngineTests
|
||||
Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex));
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------------------------
|
||||
|
||||
private static void AssertValid(NestJob job, NestJobResult result)
|
||||
{
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var runs = materialized.Nest.Plates.Select(plate => (Plate: plate, Parts: plate.Parts.ToList())).ToList();
|
||||
var requirements = job.Parts.ToDictionary<NestJobPart, Drawing, (string Name, int Quantity)>(
|
||||
p => materialized.DrawingsByPartId[p.Id],
|
||||
p => (p.Id, p.Quantity),
|
||||
ReferenceEqualityComparer.Instance
|
||||
);
|
||||
var validation = NestValidator.Validate(runs, requirements);
|
||||
NestValidator.ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), validation);
|
||||
Assert.True(validation.Valid, string.Join(Environment.NewLine, validation.Violations));
|
||||
|
||||
foreach (var f in result.Fulfillment)
|
||||
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||
}
|
||||
|
||||
private static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
|
||||
new(parts, stock, options);
|
||||
|
||||
private static NestJobPart Part(string id, Program program, int quantity, RotationPolicy? rotation = null) =>
|
||||
new(id, PartGeometrySnapshot.FromProgram(program), quantity, 0, rotation);
|
||||
|
||||
/// <param name="width">Y extent.</param>
|
||||
/// <param name="length">X extent.</param>
|
||||
private static NestPlateStock Stock(
|
||||
string id,
|
||||
double width,
|
||||
double length,
|
||||
double spacing = 0,
|
||||
Spacing edge = default,
|
||||
int quadrant = 1,
|
||||
int? quantity = null
|
||||
) => new(id, new Size(width, length), quantity, spacing, edge, quadrant);
|
||||
|
||||
private static Program Polyline(params (double X, double Y)[] points)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
||||
foreach (var (x, y) in points.Skip(1))
|
||||
program.Codes.Add(new LinearMove(x, y));
|
||||
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
||||
return program;
|
||||
}
|
||||
|
||||
private static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
|
||||
|
||||
private static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
|
||||
|
||||
private static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
|
||||
|
||||
private static Program Disc(double r)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(r, 0));
|
||||
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
|
||||
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class Qwen38FlashNextContractTests : EngineContractTests<Qwen38FlashNextNestingEngine> { }
|
||||
|
||||
Reference in New Issue
Block a user