refactor(engine): retire the StockLadder engine

Every whole-job engine chooses among the offered stock sizes, and on the
synthetic benchmark nests Default matched or beat StockLadder on all 18
runs (saved sizes, a six-size pool, with and without salvage credit),
costing 30-40% less in total with mixed sizes. Its sheet-swap step never
produced a win over Irregular's tail re-plan.

The registry maps "StockLadder" to Default so saved selections, scripts
and API requests keep working. OrderedPlateNester stays: Fill and the
remnant strategies use it for restricted rotations, and its golden layout
is now pinned through NestJobRunner with the parts requested largest
first, as StockLadder requested them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-25 20:24:37 -04:00
1 parent 14ba2e653b
commit 7c4276a457
14 files changed
+30 -646

No files matched your search

+1 -1
View File
@@ -115,7 +115,7 @@ static class NestConsole
Console.Error.WriteLine(
engine.IsJobEngine
? $"Error: engine '{engine.Name}' is a whole-job engine; single-plate fill supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use --autonest for whole-job engines."
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as StockLadder require --autonest)"
: $"Error: unknown engine '{engine.Name}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)} (whole-job engines such as Irregular require --autonest)"
);
return 1;
}
+11 -36
View File
@@ -1,6 +1,8 @@
using System.Globalization;
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using Xunit;
@@ -293,13 +295,18 @@ public class GoldenLayoutTests
/// <summary>
/// OrderedPlateNester has no legacy counterpart, so it is pinned with plain deterministic
/// poses (via its production wiring: StockLadderNestingEngine's default nester) instead of
/// a legacy-parity comparison.
/// poses instead of a legacy-parity comparison. Parts are requested largest first.
/// </summary>
[Fact]
public void OrderedViaStockLadder_MixedJob_GoldenLayout()
public void Ordered_MixedJob_GoldenLayout()
{
var result = SolveStable(new StockLadderNestingEngine(), MixedJob("Fill"));
var mixed = MixedJob("Fill");
var job = new NestJob(
mixed.Parts.OrderByDescending(p => DrawingJobMapper.CreateDrawing(p).Area),
mixed.Plates,
mixed.Options
);
var result = SolveStable(new NestJobRunner(_ => new OrderedPlateNester()), job);
Assert.Equal(NestJobStatus.Complete, result.Status);
AssertGolden(
result,
@@ -371,38 +378,6 @@ public class GoldenLayoutTests
}
}
[Fact]
public void StockLadder_ProgressStream_GoldenSequence()
{
var stages = new List<AuthoritativeStage>();
var legacyPhases = new SortedSet<string>();
var result = new StockLadderNestingEngine().Solve(
ProgressJob(),
new StageCollector(stages, legacyPhases)
);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(
new[]
{
new AuthoritativeStage(NestJobStage.EvaluatingCandidate, "small", 0, 0),
new AuthoritativeStage(NestJobStage.EvaluatingCandidate, "large", 0, 0),
new AuthoritativeStage(NestJobStage.EvaluatingCandidate, "small", 0, 0),
new AuthoritativeStage(NestJobStage.EvaluatingCandidate, "large", 0, 0),
new AuthoritativeStage(NestJobStage.EvaluatingCandidate, "small", 0, 0),
new AuthoritativeStage(NestJobStage.EvaluatingCandidate, "large", 0, 0),
new AuthoritativeStage(NestJobStage.PlateCommitted, "small", 1, 4),
new AuthoritativeStage(NestJobStage.EvaluatingCandidate, "small", 1, 4),
new AuthoritativeStage(NestJobStage.EvaluatingCandidate, "large", 1, 4),
new AuthoritativeStage(NestJobStage.PlateCommitted, "small", 2, 9),
new AuthoritativeStage(NestJobStage.EvaluatingCandidate, "small", 2, 9),
},
stages
);
// StockLadder evaluates its nester without a progress sink.
Assert.Empty(legacyPhases);
}
[Fact]
public void FixedStrategy_ProgressStream_GoldenSequence()
{
@@ -35,9 +35,6 @@ public class NestJobCostTests
Assert.Equal(expected, LegacyNestJobCost.EstimateNetArea(job, sheet));
Assert.Equal(expected, NestJobCost.NetSheetArea(job, sheet));
Assert.Equal(expected, NestJobCost.NetSheetArea(job.Options, stock, new Box(x, y, width, height)));
#pragma warning disable CS0618 // Compatibility API must retain the old result.
Assert.Equal(expected, StockLadderNestingEngine.EstimateNetArea(job, sheet));
#pragma warning restore CS0618
}
[Theory]
@@ -68,7 +65,7 @@ public class NestJobCostTests
[InlineData(0.5, 7, 100)]
[InlineData(0.5, 6, 70)]
[InlineData(0.5, 5, 70)]
public void StockLadderFixtureRetainsThresholds(double rate, double minimum, double expected)
public void NetSheetAreaRetainsLegacyThresholds(double rate, double minimum, double expected)
{
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1);
var stock = new NestPlateStock("sheet", new Size(10, 10));
+1 -2
View File
@@ -7,7 +7,7 @@ public class StockCostTests
{
public static IEnumerable<object[]> Engines()
{
foreach (var engine in new[] { "Rectangles", "Irregular", "StockLadder", "Default", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" })
foreach (var engine in new[] { "Rectangles", "Irregular", "Default", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" })
foreach (var factor in new[] { 1e-12, 1.0, 100.0 })
yield return new object[] { engine, factor };
}
@@ -91,7 +91,6 @@ public class StockCostTests
[Theory]
[InlineData("Rectangles")]
[InlineData("Irregular")]
[InlineData("StockLadder")]
[InlineData("Default")]
[InlineData("Fill")]
public void SameSizeStockRetainsIndependentFiniteInventory(string engine)
@@ -1,329 +0,0 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class StockLadderTests
{
[Fact]
public void LowerPriorityNumberWinsScarceStock()
{
var geometry = PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4));
var high = new NestJobPart("high", geometry, 1);
var job = new NestJob(
new[] { new NestJobPart("low", geometry, 1, priority: 9), high },
new[] { new NestPlateStock("only", new Size(4, 4), 1) }
);
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(0, high.Priority);
Assert.Equal("high", Assert.Single(Assert.Single(result.Plates).Placements).PartId);
Assert.Equal(0, result.Fulfillment.Single(f => f.PartId == "low").Placed);
}
private static NestJobPart Rectangle(
string id,
int quantity,
double x = 4,
double y = 4,
RotationPolicy? rotation = null
) =>
new(
id,
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(x, y)),
quantity,
rotation: rotation ?? RotationPolicy.Fixed(0)
);
[Fact]
public void MergesEquivalentDemandOntoLargerSheetAndReturnsFiniteStock()
{
var job = new NestJob(
new[] { Rectangle("a", 5) },
new[]
{
new NestPlateStock("small", new Size(10, 10), 2),
new NestPlateStock("large", new Size(10, 18), 1),
}
);
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal("large", Assert.Single(result.Plates).StockId);
Assert.Equal(5, Assert.Single(result.Fulfillment).Placed);
Assert.Equal(0, result.StockUsage.Single(s => s.StockId == "small").Used);
Assert.Equal(2, result.StockUsage.Single(s => s.StockId == "small").Remaining);
Verify(job, result);
}
[Fact]
public void FiniteStockAndPlateLimitDoNotOverproduce()
{
var parts = new[] { Rectangle("a", 9) };
var stock = new[] { new NestPlateStock("only", new Size(10, 10), 1) };
var job = new NestJob(parts, stock);
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
Assert.Equal(4, result.Fulfillment[0].Placed);
Verify(job, result);
job = new NestJob(
parts,
new[] { new NestPlateStock("only", new Size(10, 10)) },
new NestJobOptions(maxPlates: 1)
);
result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
Assert.Single(result.Plates);
Verify(job, result);
}
[Fact]
public void ConstrainedLargeSinglePrecedesSmallFillers()
{
var job = new NestJob(
new[] { Rectangle("small", 12, 2, 2), Rectangle("large", 1, 12, 6) },
new[]
{
new NestPlateStock("small-sheet", new Size(10, 10)),
new NestPlateStock("large-sheet", new Size(10, 18)),
}
);
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal("large", result.Plates[0].Placements[0].PartId);
Assert.Contains(result.Plates[0].Placements, p => p.PartId == "small");
Assert.Equal(NestJobStatus.Complete, result.Status);
Verify(job, result);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void GeometrySpacingRotationsAndQuadrantsAreValidated(int quadrant)
{
var job = new NestJob(
new[] { Rectangle("a", 6, 3, 5, RotationPolicy.Fixed(System.Math.PI / 2)) },
new[]
{
new NestPlateStock(
"sheet",
new Size(12, 18),
partSpacing: 0.25,
edgeSpacing: new Spacing
{
Left = 0.5,
Right = 0.5,
Top = 0.5,
Bottom = 0.5,
},
quadrant: quadrant
),
}
);
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Verify(job, result);
}
[Fact]
public void ImpossibleDemandTerminatesWithoutUsingUnlimitedStock()
{
var job = new NestJob(
new[] { Rectangle("a", 1, 100, 100) },
new[] { new NestPlateStock("sheet", new Size(10, 10)) }
);
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
Assert.Empty(result.Plates);
}
[Fact]
public void CancellationBeforeAndDuringTrialNeverReturnsPartialSuccess()
{
var job = new NestJob(
new[] { Rectangle("a", 1) },
new[] { new NestPlateStock("s", new Size(10, 10)) }
);
using var cts = new CancellationTokenSource();
var engine = new StockLadderNestingEngine(() =>
new CallbackNester(request =>
{
cts.Cancel();
return new PlateCandidate(Array.Empty<NestJobPlacement>());
})
);
Assert.Throws<OperationCanceledException>(() => engine.Solve(job, token: cts.Token));
Assert.Throws<OperationCanceledException>(() =>
new StockLadderNestingEngine().Solve(job, token: cts.Token)
);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void RejectsOverlappingOrOverproducingNester(bool overproduce)
{
var job = new NestJob(
new[] { Rectangle("a", 2) },
new[] { new NestPlateStock("s", new Size(10, 10)) }
);
var engine = new StockLadderNestingEngine(() =>
new CallbackNester(request => new PlateCandidate(
overproduce
? Enumerable.Repeat(new NestJobPlacement("a", 0, 0, 0, 0), 3)
: new[] { new NestJobPlacement("a", 0, 50, 0, 0) }
))
);
Assert.Throws<InvalidOperationException>(() => engine.Solve(job));
// Direct full-demand overlap check, not masked by the single-part feasibility probe limit.
Assert.Throws<InvalidOperationException>(() =>
NestJobValidator.ValidateCandidate(
new PlateCandidate(
new[]
{
new NestJobPlacement("a", 0, 0, 0, 0),
new NestJobPlacement("a", 1, 1, 1, 0),
}
),
job.Plates[0],
new Dictionary<string, int> { ["a"] = 2 },
job.Parts.ToDictionary(p => p.Id)
)
);
}
[Fact]
public void SalvageCreditsOnlyOneUsableEdgeRectangleAndDefaultsToZero()
{
var part = Rectangle("a", 1);
var stock = new NestPlateStock("s", new Size(10, 10));
var sheet = new NestJobPlateResult(
0,
stock,
new[] { new NestJobPlacement("a", 0, 0, 0, 0) }
);
NestJob Job(double rate, double min) =>
new(
new[] { part },
new[] { stock },
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: min)
);
Assert.Equal(100, NestJobCost.NetSheetArea(Job(0.5, 0), sheet));
Assert.Equal(100, NestJobCost.NetSheetArea(Job(0.5, 7), sheet));
Assert.Equal(70, NestJobCost.NetSheetArea(Job(0.5, 5), sheet), 6);
Assert.Throws<ArgumentOutOfRangeException>(() =>
new NestJobOptions(salvageRate: double.NaN)
);
Assert.Throws<ArgumentOutOfRangeException>(() => new NestJobOptions(salvageRate: 1.1));
}
[Fact]
public void FailedRepackRetainsAllDemandAndFiniteStockAccounting()
{
var job = new NestJob(
new[] { Rectangle("a", 5) },
new[]
{
new NestPlateStock("small", new Size(10, 10), 2),
new NestPlateStock("large", new Size(10, 18), 1),
}
);
var fullDemandLargeTrials = 0;
var engine = new StockLadderNestingEngine(() =>
new CallbackNester(request =>
{
var quantity = Assert.Single(request.Parts).Quantity;
if (request.Stock.Id == "large" && quantity == 5)
fullDemandLargeTrials++;
// Deliberately fail to reproduce the fifth piece on the cheaper merged sheet.
return new PlateCandidate(
Enumerable
.Range(0, System.Math.Min(quantity, 4))
.Select(i => new NestJobPlacement("a", i, i % 2 * 4, i / 2 * 4, 0))
);
})
);
var result = engine.Solve(job);
Assert.True(fullDemandLargeTrials >= 2); // Construction AND equivalent-demand repack ran.
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Plates.Count);
Assert.All(result.Plates, sheet => Assert.Equal("small", sheet.StockId));
Assert.Equal(5, Assert.Single(result.Fulfillment).Placed);
Assert.Equal(0, Assert.Single(result.Fulfillment).Unplaced);
Assert.Equal(0, result.StockUsage.Single(s => s.StockId == "large").Used);
Assert.Equal(1, result.StockUsage.Single(s => s.StockId == "large").Remaining);
Verify(job, result);
}
[Theory]
[InlineData(3.0, false)]
[InlineData(4.0001, true)]
public void OpenMarkMustRemainInsideClosedMaterial(double endX, bool reject)
{
var program = TestDrawingFactory.Rectangle(4, 4);
program.MoveTo(2, 2);
program.LineTo(endX, 2);
var part = new NestJobPart("exterior-mark", PartGeometrySnapshot.FromProgram(program), 1);
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("s", new Size(10, 10)) });
if (reject)
{
var error = Assert.Throws<ArgumentException>(() =>
new StockLadderNestingEngine().Solve(job)
);
Assert.Contains("Open geometry leaves the closed material region", error.Message);
}
else
{
var result = new StockLadderNestingEngine().Solve(job);
Assert.Equal(NestJobStatus.Complete, result.Status);
Verify(job, result);
}
}
private static void Verify(NestJob job, NestJobResult result)
{
var parts = job.Parts.ToDictionary(p => p.Id);
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity);
foreach (var sheet in result.Plates)
{
NestJobValidator.ValidateCandidate(
new PlateCandidate(sheet.Placements),
sheet.Stock,
remaining,
parts
);
foreach (var pose in sheet.Placements)
remaining[pose.PartId]--;
}
foreach (var part in job.Parts)
{
var poses = result
.Plates.SelectMany(p => p.Placements)
.Where(p => p.PartId == part.Id)
.ToList();
Assert.Equal(Enumerable.Range(0, poses.Count), poses.Select(p => p.InstanceIndex));
var fulfillment = result.Fulfillment.Single(p => p.PartId == part.Id);
Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced);
Assert.Equal(poses.Count, fulfillment.Placed);
}
foreach (var stock in job.Plates)
{
var count = result.Plates.Count(p => p.StockId == stock.Id);
var usage = result.StockUsage.Single(s => s.StockId == stock.Id);
Assert.Equal(count, usage.Used);
Assert.Equal(stock.Quantity - count, usage.Remaining);
Assert.True(stock.Quantity == null || count <= stock.Quantity);
}
}
private sealed class CallbackNester(Func<PlatePlacementRequest, PlateCandidate> callback)
: IPlateNester
{
public PlateCandidate Place(
PlatePlacementRequest request,
IProgress<NestJobProgress>? progress = null,
CancellationToken token = default
) => callback(request);
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ public sealed class NestJobOptions
MinimumSalvageDimension = minimumSalvageDimension;
}
/// <summary>Fraction of eligible edge-offcut area credited by StockLadder (0..1).</summary>
/// <summary>Fraction of eligible edge-offcut area credited by <see cref="NestJobCost.NetSheetCost"/> (0..1).</summary>
public double SalvageRate { get; }
/// <summary>Both offcut dimensions must meet this caller-supplied minimum in job units.
@@ -32,6 +32,8 @@ public static class NestingEngineRegistry
{
["Opus55NestingEngine"] = "Irregular",
["RectanglesNestingEngine"] = "Rectangles",
// Retired: Default chooses stock at least as well on every benchmark job.
["StockLadder"] = "Default",
};
static NestingEngineRegistry()
@@ -67,12 +69,6 @@ public static class NestingEngineRegistry
() => new FlatBarNestingEngine()
);
Register(
"StockLadder",
"Caller-stock constrained-first fill and equivalent-demand area repacking",
() => new StockLadderNestingEngine()
);
Register(
"Fill",
"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
@@ -1,248 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
namespace OpenNest.Engine.Jobs;
/// <summary>
/// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements
/// must reproduce exactly the removed demand and reduce net sheet cost; inventory is transactional.
/// This is a deterministic heuristic, not an optimality or geometric impossibility proof.
/// </summary>
public sealed class StockLadderNestingEngine : INestingEngine
{
private readonly Func<IPlateNester> factory;
public StockLadderNestingEngine()
: this(() => new OrderedPlateNester()) { }
public StockLadderNestingEngine(Func<IPlateNester> factory) =>
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
public NestJobResult Solve(
NestJob job,
IProgress<NestJobProgress> progress = null,
CancellationToken token = default
)
{
ArgumentNullException.ThrowIfNull(job);
token.ThrowIfCancellationRequested();
NestJobValidator.Validate(job);
var nester = factory() ?? throw new InvalidOperationException("Null plate nester.");
var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
var areas = job.Parts.ToDictionary(p => p.Id, p => DrawingJobMapper.CreateDrawing(p).Area);
var sheets = new List<NestJobPlateResult>();
var feasible = job.Parts.ToDictionary(p => p.Id, _ => new HashSet<string>());
// Probe actual validated single-part placements, not bounding-box fit assertions.
foreach (var part in job.Parts)
foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
{
var probe = Trial(stock, new[] { WithQuantity(part, 1) });
if (probe.Placements.Count != 0)
feasible[part.Id].Add(stock.Id);
}
var ordered = job
.Parts.OrderBy(p => p.Priority)
.ThenBy(p => feasible[p.Id].Count)
.ThenByDescending(p => areas[p.Id])
.ToList();
var reason = NestJobStopReason.Completed;
while (remaining.Values.Any(n => n > 0))
{
token.ThrowIfCancellationRequested();
if (job.Options.MaxPlates <= sheets.Count)
{
if (Consolidate())
continue;
reason = NestJobStopReason.PlateLimitReached;
break;
}
var available = job
.Plates.Where(s => s.Quantity == null || used[s.Id] < s.Quantity)
.ToList();
if (available.Count == 0)
{
if (Consolidate())
continue;
reason = NestJobStopReason.StockExhausted;
break;
}
var anchor = ordered.FirstOrDefault(p =>
remaining[p.Id] > 0 && available.Any(s => feasible[p.Id].Contains(s.Id))
);
if (anchor == null)
{
reason = NestJobStopReason.NoPlacementFound;
break;
}
NestJobPlateResult winner = null;
var score = double.PositiveInfinity;
foreach (var stock in available.Where(s => feasible[anchor.Id].Contains(s.Id)))
{
// Pin the constrained anchor before fillers, including quantity-one requirements.
var requests = new[] { anchor }
.Concat(ordered.Where(p => p.Id != anchor.Id))
.Where(p => remaining[p.Id] > 0)
.Select(p => WithQuantity(p, remaining[p.Id]));
var candidate = Trial(stock, requests);
if (!candidate.Placements.Any(p => p.PartId == anchor.Id))
continue;
var sheet = new NestJobPlateResult(sheets.Count, stock, candidate.Placements);
// Initial construction only: material area, never raw part counts. Repacking below
// compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill.
var value =
NestJobCost.RequireFinite(NestJobCost.NetSheetCost(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]));
if (value < score - (stock.Cost.HasValue ? 0 : 1e-9))
{
winner = sheet;
score = value;
}
}
if (winner == null)
{
reason = NestJobStopReason.NoPlacementFound;
break;
}
sheets.Add(winner);
used[winner.StockId]++;
foreach (var pose in winner.Placements)
remaining[pose.PartId]--;
progress?.Report(
new NestJobProgress(
NestJobStage.PlateCommitted,
winner.StockId,
sheets.Count - 1,
sheets.Count,
sheets.Sum(s => s.Placements.Count)
)
);
}
Consolidate();
token.ThrowIfCancellationRequested();
var placed = job.Parts.ToDictionary(p => p.Id, _ => 0);
var final = sheets
.Select(
(sheet, index) =>
new NestJobPlateResult(
index,
sheet.Stock,
sheet
.Placements.Select(p => p with { InstanceIndex = placed[p.PartId]++ })
.ToList()
)
)
.ToList();
return new NestJobResult(
reason == NestJobStopReason.Completed
? NestJobStatus.Complete
: NestJobStatus.Incomplete,
reason,
final,
job.Parts.Select(p => new PartFulfillment(
p.Id,
p.Quantity,
placed[p.Id],
remaining[p.Id]
)),
job.Plates.Select(s => new StockUsage(s.Id, used[s.Id], s.Quantity - used[s.Id]))
);
PlateCandidate Trial(NestPlateStock stock, IEnumerable<NestJobPart> requirements)
{
token.ThrowIfCancellationRequested();
var request = new PlatePlacementRequest(stock, requirements);
progress?.Report(
new NestJobProgress(
NestJobStage.EvaluatingCandidate,
stock.Id,
sheets.Count,
sheets.Count,
sheets.Sum(s => s.Placements.Count)
)
);
var candidate = nester.Place(request, null, token);
token.ThrowIfCancellationRequested();
NestJobValidator.ValidateCandidate(
candidate,
stock,
request.Parts.ToDictionary(p => p.Id, p => p.Quantity),
parts
);
return candidate;
}
bool Consolidate()
{
var changed = false;
// Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree.
for (var index = 0; index < sheets.Count; index++)
for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--)
{
var old = sheets.Skip(index).Take(count).ToList();
var demand = old.SelectMany(s => s.Placements)
.GroupBy(p => p.PartId)
.ToDictionary(g => g.Key, g => g.Count());
var baseline = NestJobCost.RequireFinite(old.Sum(s => NestJobCost.NetSheetCost(job, s)));
NestJobPlateResult replacement = null;
foreach (var stock in job.Plates)
{
token.ThrowIfCancellationRequested();
var returned = old.Count(s => s.StockId == stock.Id);
if (stock.Quantity is int limit && used[stock.Id] - returned >= limit)
continue;
// Even the maximum possible salvage credit cannot beat the incumbent.
var lowerBound =
NestJobCost.GrossSheetCost(stock) * (1 - job.Options.SalvageRate);
if (lowerBound >= baseline - (stock.Cost.HasValue ? 0 : 1e-9))
continue;
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id)))
continue;
var candidate = Trial(
stock,
ordered
.Where(p => demand.ContainsKey(p.Id))
.Select(p => WithQuantity(p, demand[p.Id]))
);
var actual = candidate
.Placements.GroupBy(p => p.PartId)
.ToDictionary(g => g.Key, g => g.Count());
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value))
continue;
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
var cost = NestJobCost.NetSheetCost(job, trial);
if (cost >= baseline - (stock.Cost.HasValue ? 0 : 1e-9))
continue;
baseline = cost;
replacement = trial;
}
if (replacement == null)
continue;
// No accounting changes until the entire equivalent-demand candidate is valid.
foreach (var sheet in old)
used[sheet.StockId]--;
used[replacement.StockId]++;
sheets.RemoveRange(index, count);
sheets.Insert(index, replacement);
changed = true;
}
return changed;
}
}
private static NestJobPart WithQuantity(NestJobPart part, int quantity) =>
new(part.Id, part.Geometry, quantity, part.Priority, part.Rotation);
/// <summary>Full physical sheet area minus a conservative offcut estimate. Credits only ONE
/// empty full-span edge rectangle outside every placed bounding box plus part clearance, within
/// the usable work area, and meeting the caller's minimum in both dimensions. Not a certified
/// remnant: no cut-off toolpath, kerf, handling, or future-demand valuation is modelled.</summary>
[Obsolete("Use NestJobCost.NetSheetArea instead.")]
public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet) =>
NestJobCost.NetSheetArea(job, sheet);
}
-1
View File
@@ -34,7 +34,6 @@ namespace OpenNest.Mcp.Tools
+ "Fill: many copies of few drawings; lattice fill with pairs and rectangle best-fit. "
+ "Strip: Fill variant laying mixed drawings in strips. "
+ "Vertical Remnant, Horizontal Remnant: Fill variants keeping a right-side or top-side drop. "
+ "StockLadder: constrained parts first, then area repacking; meant for several stock sizes. "
+ "Plug-ins loaded from Engines/ use their class name.";
public NestingTools(NestSession session)
@@ -9,7 +9,6 @@ namespace OpenNest.Tests.Benchmark;
public class NestLayoutCheckEquivalenceTests
{
[Theory]
[InlineData("StockLadder")]
[InlineData("Fill")]
[InlineData("Strip")]
[InlineData("Vertical Remnant")]
+3 -9
View File
@@ -13,15 +13,12 @@ namespace OpenNest.Forms
/// App-scoped nesting-engine selection. The selected name addresses a whole-job
/// INestingEngine resolved through NestingEngineRegistry at call time; single-plate
/// interactive fill uses FillStrategy, which maps a built-in engine to its placement
/// strategy and falls back to Fill for jobs-only engines (StockLadder, plug-ins).
/// strategy and falls back to Fill for jobs-only engines and plug-ins.
/// </summary>
public static class EngineSelection
{
public const string DefaultEngineName = EngineSelectionSettings.DefaultEngineName;
/// <summary>Registered jobs engine deliberately kept out of the desktop combo.</summary>
public const string HiddenEngineName = "StockLadder";
private static string engineName = DefaultEngineName;
/// <summary>Current selection. User changes are persisted for the next launch.</summary>
@@ -59,12 +56,9 @@ namespace OpenNest.Forms
return statusMessage ?? string.Empty;
}
/// <summary>Desktop combo contents: registered jobs engines minus StockLadder.</summary>
/// <summary>Desktop combo contents: every registered jobs engine.</summary>
public static IEnumerable<string> UiEngineNames =>
NestingEngineRegistry.AvailableEngines
.Where(e => !e.Name.Equals(HiddenEngineName, StringComparison.OrdinalIgnoreCase))
.Select(e => e.Name)
.ToList();
NestingEngineRegistry.AvailableEngines.Select(e => e.Name).ToList();
/// <summary>Placement strategy for single-plate fill: the selection when it names a
/// built-in strategy, otherwise Fill.</summary>
+1 -1
View File
@@ -27,7 +27,7 @@ Auto Nest starts on empty/new sheets and does not change existing populated plat
Progress is owned and modal so the input drawings cannot be edited while a worker uses them. Stop or closing progress cancels and discards the whole proposal. The dialog waits for the worker to finish; no engine has an Accept-early button on this path. A plug-in that ignores the cancellation token cannot commit its late result.
Auto Nest never draws its parts on the plate view before they are kept: its result is a set of sheets, possibly of several sizes, not parts on the current plate. Ghost (preview) parts on the plate view mean only "what this plate gets if you accept now", and stay with interactive Fill and the remnant tools. Instead the progress window shows **Best plan so far**: every sheet of the engine's best whole-job plan drawn side by side at one scale, identical sheets once with their count, each captioned with size, part count and utilization. An engine reports a plan with `NestJobStage.PlanImproved` and `NestJobProgress.Plan`; `NestPipeline` adds `PlanSheets`, parts on detached drawings built from the job geometry, so nothing touches the caller's drawings or quantities. The plan is unvalidated and may be replaced. Default, Irregular, Rectangles and Circles report plans (see [nesting engines](nesting-engines.md#best-plan-so-far)); FlatBar, StockLadder, the fixed-strategy engines and plug-ins that report none show the strip empty until the review.
Auto Nest never draws its parts on the plate view before they are kept: its result is a set of sheets, possibly of several sizes, not parts on the current plate. Ghost (preview) parts on the plate view mean only "what this plate gets if you accept now", and stay with interactive Fill and the remnant tools. Instead the progress window shows **Best plan so far**: every sheet of the engine's best whole-job plan drawn side by side at one scale, identical sheets once with their count, each captioned with size, part count and utilization. An engine reports a plan with `NestJobStage.PlanImproved` and `NestJobProgress.Plan`; `NestPipeline` adds `PlanSheets`, parts on detached drawings built from the job geometry, so nothing touches the caller's drawings or quantities. The plan is unvalidated and may be replaced. Default, Irregular, Rectangles and Circles report plans (see [nesting engines](nesting-engines.md#best-plan-so-far)); FlatBar, the fixed-strategy engines and plug-ins that report none show the strip empty until the review.
After a valid result, or an invalid one kept in the validation report, a review window shows the final sheets in the same strip with totals, the supplied-cost summary and every unplaced drawing, count and reason. **Keep** (Enter) commits the whole proposal; **Discard** (Escape) applies nothing.
+8 -6
View File
@@ -16,7 +16,6 @@ in `OpenNest.Engine/NestingEngines/<Name>/`, its tests in `OpenNest.Engine.Tests
| Circles | Round parts: blanks, washers, flanges, gears by their tip circle | Each part packed as its minimum enclosing circle at centers tangent to two of walls, placed discs and clamp keep-outs, with hexagonal and square lattice seeds; bids only when every part is round; stock chosen as Rectangles does, plus whole-job runs preferring each stock and a tail re-plan |
| Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan |
| FlatBar | Flat bar: parts about as wide as the bar | Each stock read as a bar and cut in one row: the CutList cut-list packer and a best-fill search on part lengths less their self-nesting gain, then a slide-left pass that nests shaped ends and refills freed length; stock chosen bar by bar by whole-plan cost |
| StockLadder | Caller-supplied stock ladders | Constrained-first fill with equivalent-demand area repacking |
| Fill, Strip, Vertical Remnant, Horizontal Remnant | Single-strategy fills | The fixed placement strategies behind interactive fill; Fill is the multi-phase lattice fill (linear, pairs, rectangle best-fit, remainder) |
Neither Irregular nor Rectangles wins every job, even within its own lane, so Default runs both
@@ -48,7 +47,7 @@ progress sink.
plans is offered the same way. When the chosen result is not the last plan reported (a
candidate's plan can outscore every returned result), Default reports it last.
Plug-ins may report plans the same way; FlatBar, StockLadder and the fixed-strategy engines do not.
Plug-ins may report plans the same way; FlatBar and the fixed-strategy engines do not.
Interactive Fill shows its rectangle best-fit (box packing) layout before the best-fit pair
phase, whose first computation for a drawing can take a minute, so **Accept** is available at
@@ -368,12 +367,17 @@ desktop selections, scripts and API requests keep working:
|---|---|
| `Opus55NestingEngine` | Irregular |
| `RectanglesNestingEngine` | Rectangles |
| `StockLadder` | Default |
In v0.3.0 and earlier, `Default` was the multi-phase fill engine now named Fill. `Default` now names the
choosing engine above, so saved selections, scripts and API requests that name it get that
engine. Fill-strategy calls (interactive fill, `PlateFillService`, console fill without
`--autonest`, MCP fill tools) still read `Default` as Fill.
StockLadder, a greedy constrained-first engine kept out of the desktop list, was retired
because Default matched or beat it on every benchmark job, with and without salvage credit and
mixed sheet sizes. Every whole-job engine chooses among the offered stock sizes.
Gpt6Astra and Qwen38FlashNext are no longer shipped and have no alias. A saved selection of either
falls back to the default engine with the usual status-bar warning.
@@ -403,14 +407,12 @@ Leftover Gpt6Astra or Qwen38FlashNext DLLs still load as ordinary plug-ins until
`NestPlateStock.Cost` is nullable, immutable and per physical sheet. Every available
stock must have a finite positive cost, or all must omit it. Omitted costs preserve
area scoring. The original constructor and area-valued `NetSheetArea` and obsolete
`EstimateNetArea` APIs retain their units. `NetSheetCost` applies proportional salvage
area scoring. The original constructor and area-valued `NetSheetArea` retain their units. `NetSheetCost` applies proportional salvage
to explicit costs; geometric area still controls fit, utilization and material ordering.
Rectangles uses net cost in sheet trials and remaining-material estimates. Irregular
uses it in look-ahead, strategy comparison and equivalent-demand tail replacement.
StockLadder ranks net cost per placed material area and uses total net cost for
consolidation. Default compares valid equally fulfilled results by generic job score.
Default compares valid equally fulfilled results by generic job score.
Fixed Fill/Strip/remnant strategies rank priority fulfillment first, then net cost
for priced stock (gross area for legacy unpriced stock), then existing stable ties.
None guarantees globally optimal purchasing or explores every combination of patterns.
+1 -1
View File
@@ -20,7 +20,7 @@ try
var create = registry.GetMethod("Create")!;
// Every built-in engine the desktop offers must instantiate from the packaged assembly.
string[] expected = ["Default", "Rectangles", "Irregular", "Circles", "FlatBar", "StockLadder", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant"];
string[] expected = ["Default", "Rectangles", "Irregular", "Circles", "FlatBar", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant"];
foreach (var name in expected)
{
var engine = create.Invoke(null, [name])!;