diff --git a/OpenNest.Benchmark/BenchmarkJob.cs b/OpenNest.Benchmark/BenchmarkJob.cs
index c3a4140..9704114 100644
--- a/OpenNest.Benchmark/BenchmarkJob.cs
+++ b/OpenNest.Benchmark/BenchmarkJob.cs
@@ -48,13 +48,13 @@ namespace OpenNest.Benchmark
/// engine owns its own multi-plate/size strategy; this harness no
/// longer picks plate sizes on the engine's behalf.
///
- public NestJob BuildNestJob(int maxPlates)
+ public NestJob BuildNestJob(int maxPlates, double salvageRate = 0, double minimumSalvageDimension = 0)
{
var parts = Requests.Select(r =>
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity));
var stock = CandidateSizes.Select(size =>
new NestPlateStock(size.ToString(1), size, null, PartSpacing, EdgeSpacing, Quadrant));
- return new NestJob(parts, stock, new NestJobOptions("Default", maxPlates));
+ return new NestJob(parts, stock, new NestJobOptions("Default", maxPlates, salvageRate, minimumSalvageDimension));
}
}
}
diff --git a/OpenNest.Benchmark/BenchmarkRunner.cs b/OpenNest.Benchmark/BenchmarkRunner.cs
index 0273782..133a6a9 100644
--- a/OpenNest.Benchmark/BenchmarkRunner.cs
+++ b/OpenNest.Benchmark/BenchmarkRunner.cs
@@ -23,7 +23,8 @@ namespace OpenNest.Benchmark
/// Wall-clock budget for one engine solving one job.
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
- public static List Run(List jobs, IReadOnlyList engines)
+ public static List Run(List jobs, IReadOnlyList engines,
+ double salvageRate = 0, double minimumSalvageDimension = 0, string outputDirectory = null)
{
var results = new List(jobs.Count * engines.Count);
@@ -31,21 +32,22 @@ namespace OpenNest.Benchmark
{
foreach (var engineInfo in engines)
{
- results.Add(RunOne(job, engineInfo));
+ results.Add(RunOne(job, engineInfo, salvageRate, minimumSalvageDimension, outputDirectory));
}
}
return results;
}
- private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo)
+ private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo,
+ double salvageRate, double minimumSalvageDimension, string outputDirectory)
{
var requested = job.TotalRequestedQuantity;
var sw = Stopwatch.StartNew();
try
{
- var nestJob = job.BuildNestJob(MaxPlates);
+ var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
var engine = engineInfo.Factory();
using var cts = new CancellationTokenSource(SolveTimeout);
var jobResult = engine.Solve(nestJob, null, cts.Token);
@@ -70,6 +72,35 @@ namespace OpenNest.Benchmark
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
+ if (validation.Valid && outputDirectory != null)
+ {
+ System.IO.Directory.CreateDirectory(outputDirectory);
+ // Keep names and job metadata for a useful inspectable output; never modify source.
+ var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
+ materialized.Nest.Name = source.Name;
+ materialized.Nest.Units = source.Units;
+ materialized.Nest.Material = source.Material;
+ materialized.Nest.Thickness = source.Thickness;
+ materialized.Nest.SalvageRate = salvageRate;
+ foreach (var request in job.Requests)
+ materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request.Drawing.Name;
+ var path = System.IO.Path.Combine(outputDirectory, $"{job.Name}-{engineInfo.Name}.nest");
+ if (System.IO.Path.GetFullPath(path) == System.IO.Path.GetFullPath(job.SourceFile))
+ throw new InvalidOperationException("Output must not overwrite the source nest.");
+ new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
+ var report = new
+ {
+ Source = job.SourceFile, Engine = engineInfo.Name, jobResult.Status, jobResult.StopReason,
+ Requested = requested, Placed = totalPlaced, SheetArea = plateArea, PlacedArea = placedArea,
+ SalvageRate = salvageRate, MinimumSalvageDimension = minimumSalvageDimension,
+ EstimatedNetArea = jobResult.Plates.Sum(p => StockLadderNestingEngine.EstimateNetArea(nestJob, p)),
+ Fulfillment = jobResult.Fulfillment, StockUsage = jobResult.StockUsage,
+ Plates = jobResult.Plates, validation.Violations
+ };
+ System.IO.File.WriteAllText(System.IO.Path.ChangeExtension(path, ".json"),
+ System.Text.Json.JsonSerializer.Serialize(report,
+ new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
+ }
sw.Stop();
return new JobResult
diff --git a/OpenNest.Benchmark/Program.cs b/OpenNest.Benchmark/Program.cs
index f8e5664..0b1ded8 100644
--- a/OpenNest.Benchmark/Program.cs
+++ b/OpenNest.Benchmark/Program.cs
@@ -70,7 +70,7 @@ static class BenchmarkConsole
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
- var results = BenchmarkRunner.Run(jobs, engines);
+ var results = BenchmarkRunner.Run(jobs, engines, options.SalvageRate, options.MinimumSalvageDimension, options.OutputDirectory);
Report.PrintDetailed(results);
Report.PrintSummary(results);
@@ -111,6 +111,16 @@ static class BenchmarkConsole
o.CsvPath = args[++i];
break;
+ case "--salvage-rate" when i + 1 < args.Length:
+ o.SalvageRate = double.Parse(args[++i], System.Globalization.CultureInfo.InvariantCulture);
+ break;
+ case "--min-salvage-dimension" when i + 1 < args.Length:
+ o.MinimumSalvageDimension = double.Parse(args[++i], System.Globalization.CultureInfo.InvariantCulture);
+ break;
+ case "--output" when i + 1 < args.Length:
+ o.OutputDirectory = args[++i];
+ break;
+
case "--help":
PrintUsage();
return null;
@@ -162,6 +172,9 @@ static class BenchmarkConsole
Console.Error.WriteLine(" --spacing Override part spacing for every job");
Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)");
Console.Error.WriteLine(" --csv Write a flat CSV of all results");
+ Console.Error.WriteLine(" --salvage-rate <0..1> Fraction of eligible offcut area credited (default 0)");
+ Console.Error.WriteLine(" --min-salvage-dimension Both offcut dimensions must qualify; 0 disables credit");
+ Console.Error.WriteLine(" --output Save valid layouts as .nest plus detailed JSON reports");
Console.Error.WriteLine(" --help Show this message");
}
@@ -172,5 +185,8 @@ static class BenchmarkConsole
public double? PartSpacing;
public List EngineNames = new();
public string CsvPath;
+ public string OutputDirectory;
+ public double SalvageRate;
+ public double MinimumSalvageDimension;
}
}
diff --git a/OpenNest.Engine.Tests/Jobs/StockLadderTests.cs b/OpenNest.Engine.Tests/Jobs/StockLadderTests.cs
new file mode 100644
index 0000000..a44aa27
--- /dev/null
+++ b/OpenNest.Engine.Tests/Jobs/StockLadderTests.cs
@@ -0,0 +1,212 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Tests.Jobs;
+
+public class StockLadderTests
+{
+ 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());
+ }));
+ Assert.Throws(() => engine.Solve(job, token: cts.Token));
+ Assert.Throws(() => 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(() => engine.Solve(job));
+ // Direct full-demand overlap check, not masked by the single-part feasibility probe limit.
+ Assert.Throws(() => NestJobValidator.ValidateCandidate(
+ new PlateCandidate(new[] { new NestJobPlacement("a", 0, 0, 0, 0), new NestJobPlacement("a", 1, 1, 1, 0) }),
+ job.Plates[0], new Dictionary { ["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, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 0), sheet));
+ Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 7), sheet));
+ Assert.Equal(70, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 5), sheet), 6);
+ Assert.Throws(() => new NestJobOptions(salvageRate: double.NaN));
+ Assert.Throws(() => 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(() => 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 callback) : IPlateNester
+ {
+ public PlateCandidate Place(PlatePlacementRequest request, IProgress? progress = null,
+ CancellationToken token = default) => callback(request);
+ }
+}
diff --git a/OpenNest.Engine/Jobs/NestJobOptions.cs b/OpenNest.Engine/Jobs/NestJobOptions.cs
index 9013a8e..b9449c4 100644
--- a/OpenNest.Engine/Jobs/NestJobOptions.cs
+++ b/OpenNest.Engine/Jobs/NestJobOptions.cs
@@ -5,14 +5,27 @@ namespace OpenNest;
/// Immutable per-job options; selection never changes the legacy global registry.
public sealed class NestJobOptions
{
- public NestJobOptions(string placementStrategy = "Default", int? maxPlates = null)
+ public NestJobOptions(string placementStrategy = "Default", int? maxPlates = null,
+ double salvageRate = 0, double minimumSalvageDimension = 0)
{
ArgumentException.ThrowIfNullOrWhiteSpace(placementStrategy);
if (maxPlates <= 0) throw new ArgumentOutOfRangeException(nameof(maxPlates));
+ if (!double.IsFinite(salvageRate) || salvageRate < 0 || salvageRate > 1)
+ throw new ArgumentOutOfRangeException(nameof(salvageRate));
+ if (!double.IsFinite(minimumSalvageDimension) || minimumSalvageDimension < 0)
+ throw new ArgumentOutOfRangeException(nameof(minimumSalvageDimension));
PlacementStrategy = placementStrategy;
MaxPlates = maxPlates;
+ SalvageRate = salvageRate;
+ MinimumSalvageDimension = minimumSalvageDimension;
}
+ /// Fraction of eligible edge-offcut area credited by StockLadder (0..1).
+ public double SalvageRate { get; }
+ /// Both offcut dimensions must meet this caller-supplied minimum in job units.
+ /// Zero disables credit; scraps and holes are never credited.
+ public double MinimumSalvageDimension { get; }
+
public string PlacementStrategy { get; }
/// Maximum physical sheets to commit, or null for no explicit cap.
public int? MaxPlates { get; }
diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
index e50993a..84d7ff7 100644
--- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
+++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
@@ -76,14 +76,131 @@ internal static class NestJobPlacementValidator
var contours = ShapeBuilder.GetShapes(cutEntities);
if (contours.Count == 0) throw new ArgumentException("Geometry must contain a closed contour.");
+ var closedEntities = new List();
+ var marks = new List();
foreach (var contour in contours)
- ValidateContour(contour);
+ {
+ if (contour.IsClosed())
+ {
+ ValidateContour(contour);
+ closedEntities.AddRange(contour.Entities);
+ }
+ else marks.Add(contour);
+ }
+ if (closedEntities.Count == 0)
+ throw new ArgumentException("Geometry must contain a closed outer contour.");
- var profile = new ShapeProfile(cutEntities);
+ // ShapeProfile selects the outer profile, but does not validate containment and
+ // treats open chains as cutouts. Only validated closed contours may define material.
+ var profile = new ShapeProfile(closedEntities);
+ foreach (var cutout in profile.Cutouts)
+ ValidateInternalChain(cutout, profile.Perimeter, new List());
+ foreach (var mark in marks)
+ ValidateMark(mark, profile.Perimeter, profile.Cutouts);
profile.NormalizeWinding();
return new ShapeTopology(profile.Perimeter, profile.Cutouts);
}
+ private static void ValidateMark(Shape mark, Shape perimeter, List holes)
+ {
+ const double chordTolerance = 0.00001;
+ var boundaries = new List { perimeter };
+ boundaries.AddRange(holes);
+ var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
+ foreach (var entity in mark.Entities)
+ {
+ if (entity.Length <= Epsilon || entity is not (Line or Arc))
+ throw new ArgumentException("Unsupported or degenerate internal mark.");
+ var parameters = new List { 0, 1 };
+ foreach (var boundary in boundaries)
+ {
+ entity.Intersects(boundary, out var intersections);
+ foreach (var point in intersections)
+ AddParameter(point);
+ // Include endpoints of coincident edges (parallel intersections may be empty).
+ foreach (var point in boundary.Entities.CollectPoints())
+ if (entity.ClosestPointTo(point).DistanceTo(point) <= Epsilon)
+ AddParameter(point);
+ }
+ parameters.Sort();
+ for (var index = 0; index < parameters.Count; index++)
+ {
+ Check(PointAt(parameters[index]));
+ if (index > 0) Check(PointAt((parameters[index - 1] + parameters[index]) / 2));
+ }
+
+ void AddParameter(Vector point)
+ {
+ if (!point.IsValid()) throw new ArgumentException("Indeterminate mark intersection.");
+ var value = entity is Line line
+ ? line.StartPoint.DistanceTo(point) / line.Length
+ : Angle.NormalizeRad(((Arc)entity).IsReversed
+ ? ((Arc)entity).StartAngle - ((Arc)entity).Center.AngleTo(point)
+ : ((Arc)entity).Center.AngleTo(point) - ((Arc)entity).StartAngle) / ((Arc)entity).SweepAngle();
+ if (value >= 0 && value <= 1) parameters.Add(value);
+ }
+ Vector PointAt(double value)
+ {
+ if (entity is Line line) return line.StartPoint + (line.EndPoint - line.StartPoint) * value;
+ var arc = (Arc)entity;
+ var angle = arc.StartAngle + (arc.IsReversed ? -1 : 1) * arc.SweepAngle() * value;
+ return arc.Center + new Vector(System.Math.Cos(angle), System.Math.Sin(angle)) * arc.Radius;
+ }
+ void Check(Vector point)
+ {
+ for (var index = 0; index < boundaries.Count; index++)
+ {
+ // Exact analytic boundary contact is allowed; near-boundary uncertainty is not.
+ var onBoundary = false;
+ foreach (var edge in boundaries[index].Entities)
+ if (edge.ClosestPointTo(point).DistanceTo(point) <= Epsilon) onBoundary = true;
+ if (onBoundary) continue;
+ foreach (var edge in polygons[index].ToLines())
+ if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
+ throw new ArgumentException("Internal mark is too close to a material boundary.");
+ var inside = StrictlyInside(polygons[index], point);
+ if (index == 0 ? !inside : inside)
+ throw new ArgumentException("Open geometry leaves the closed material region.");
+ }
+ }
+ }
+ }
+
+ private static void ValidateInternalChain(Shape chain, Shape perimeter, List holes)
+ {
+ // A connected analytic entity cannot leave material without crossing its boundary.
+ // Reject contact too: conservative, rather than guessing at tangent/collinear cuts.
+ // The witness point is farther than the polygonization error from every boundary.
+ const double chordTolerance = 0.00001;
+ var boundaries = new List { perimeter };
+ boundaries.AddRange(holes);
+ var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
+ foreach (var entity in chain.Entities)
+ {
+ if (entity.Length <= Epsilon)
+ throw new ArgumentException("Geometry contains a zero-length internal edge.");
+ var point = entity switch
+ {
+ Line line => line.StartPoint,
+ Arc arc => arc.StartPoint(),
+ Circle circle => circle.Center.Offset(circle.Radius, 0),
+ _ => throw new ArgumentException("Unsupported internal geometry.")
+ };
+ if (!StrictlyInside(polygons[0], point))
+ throw new ArgumentException("Open or disconnected geometry lies outside the closed perimeter.");
+ for (var index = 0; index < boundaries.Count; index++)
+ {
+ if (index > 0 && polygons[index].ContainsPoint(point))
+ throw new ArgumentException("Internal geometry lies in a cutout.");
+ foreach (var edge in polygons[index].ToLines())
+ if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
+ throw new ArgumentException("Internal geometry is too close to a material boundary.");
+ if (entity.Intersects(boundaries[index]))
+ throw new ArgumentException("Internal geometry crosses or touches a material boundary.");
+ }
+ }
+ }
+
private static void ValidateContour(Shape contour)
{
if (!contour.IsClosed())
diff --git a/OpenNest.Engine/Jobs/NestJobValidator.cs b/OpenNest.Engine/Jobs/NestJobValidator.cs
index e4104bf..bf2191a 100644
--- a/OpenNest.Engine/Jobs/NestJobValidator.cs
+++ b/OpenNest.Engine/Jobs/NestJobValidator.cs
@@ -31,7 +31,7 @@ public static class NestJobValidator
}
catch (ArgumentException exception)
{
- throw new ArgumentException($"Geometry must contain usable closed edges: {part.Id}.", nameof(job), exception);
+ throw new ArgumentException($"Geometry must contain usable closed edges: {part.Id}. {exception.Message}", nameof(job), exception);
}
}
}
diff --git a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs
index 2403c5a..c71d045 100644
--- a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs
+++ b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs
@@ -20,6 +20,9 @@ public static class NestingEngineRegistry
static NestingEngineRegistry()
{
+ Register("StockLadder", "Caller-stock constrained-first fill and equivalent-demand area repacking",
+ () => new StockLadderNestingEngine());
+
Register("Default", "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
() => new FixedStrategyNestingEngine("Default"));
diff --git a/OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs b/OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs
new file mode 100644
index 0000000..c8476c7
--- /dev/null
+++ b/OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Engine.Fill;
+using OpenNest.Geometry;
+
+namespace OpenNest;
+
+/// Constrained-order linear fills in conservative rectangular free regions.
+/// Regions are only search hints; every accepted pose passes the job geometry validator.
+internal sealed class OrderedPlateNester : IPlateNester
+{
+ private readonly Dictionary drawings = new(StringComparer.Ordinal);
+
+ public PlateCandidate Place(PlatePlacementRequest request, IProgress progress = null,
+ CancellationToken token = default)
+ {
+ var work = DrawingJobMapper.CreatePlate(request.Stock).WorkArea();
+ var poses = new List();
+ var obstacles = new List();
+ var requirements = request.Parts.ToDictionary(p => p.Id);
+ var demand = request.Parts.ToDictionary(p => p.Id, p => p.Quantity);
+ foreach (var requirement in request.Parts)
+ {
+ token.ThrowIfCancellationRequested();
+ if (!drawings.TryGetValue(requirement.Id, out var drawing))
+ drawings.Add(requirement.Id, drawing = DrawingJobMapper.CreateDrawing(requirement));
+ var left = requirement.Quantity;
+ while (left > 0)
+ {
+ var regions = new RemnantFinder(work, obstacles).FindRemnants();
+ List best = null;
+ foreach (var region in regions)
+ {
+ foreach (var angle in Angles(requirement.Rotation))
+ {
+ token.ThrowIfCancellationRequested();
+ // FillLinear uses actual line/arc geometry for copy distances.
+ var parts = new FillLinear(region, request.Stock.PartSpacing)
+ .Fill(drawing, angle, NestDirection.Horizontal).Take(left).ToList();
+ if (parts.Count == 0 || (best != null && parts.Count <= best.Count)) continue;
+ var trial = poses.Concat(parts.Select(p => new NestJobPlacement(requirement.Id, 0,
+ p.Location.X, p.Location.Y, p.Rotation))).ToList();
+ try
+ {
+ NestJobValidator.ValidateCandidate(new PlateCandidate(trial), request.Stock, demand, requirements);
+ best = parts;
+ }
+ catch (InvalidOperationException)
+ {
+ // Geometry kernels are proposal generators, never the acceptance gate.
+ }
+ if (best?.Count == left) break;
+ }
+ if (best?.Count == left) break;
+ }
+ if (best == null) break;
+ foreach (var part in best)
+ {
+ poses.Add(new NestJobPlacement(requirement.Id, 0, part.Location.X, part.Location.Y, part.Rotation));
+ obstacles.Add(part.BoundingBox.Offset(request.Stock.PartSpacing));
+ }
+ left -= best.Count;
+ }
+ }
+ token.ThrowIfCancellationRequested();
+ return new PlateCandidate(poses);
+ }
+
+ private static IEnumerable Angles(RotationPolicy policy)
+ {
+ if (policy.Kind == RotationPolicyKind.Fixed)
+ {
+ yield return policy.Start;
+ yield break;
+ }
+ // A bounded deterministic search, not a proof that an unplaced part cannot fit.
+ if (policy.Kind == RotationPolicyKind.Automatic)
+ {
+ yield return 0;
+ yield return System.Math.PI / 2;
+ yield return System.Math.PI;
+ yield return 3 * System.Math.PI / 2;
+ for (var degrees = 5; degrees < 180; degrees += 5)
+ if (degrees != 90) yield return degrees * System.Math.PI / 180;
+ yield break;
+ }
+ for (var index = 0L; ; index++)
+ {
+ var angle = policy.Start + index * policy.Step;
+ if (angle > policy.End + 1e-9) yield break;
+ yield return angle;
+ }
+ }
+}
diff --git a/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs b/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs
new file mode 100644
index 0000000..2adcae4
--- /dev/null
+++ b/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs
@@ -0,0 +1,195 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+
+namespace OpenNest;
+
+///
+/// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements
+/// must reproduce exactly the removed demand and reduce net sheet area; inventory is transactional.
+/// This is a deterministic heuristic, not an optimality or geometric impossibility proof.
+///
+public sealed class StockLadderNestingEngine : INestingEngine
+{
+ private readonly Func factory;
+ public StockLadderNestingEngine() : this(() => new OrderedPlateNester()) { }
+ public StockLadderNestingEngine(Func factory) =>
+ this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
+
+ public NestJobResult Solve(NestJob job, IProgress 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();
+ var feasible = job.Parts.ToDictionary(p => p.Id, _ => new HashSet());
+
+ // 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 = EstimateNetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]);
+ if (value < score - 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 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 = old.Sum(s => EstimateNetArea(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 = stock.Size.Width * stock.Size.Length * (1 - job.Options.SalvageRate);
+ if (lowerBound >= baseline - 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 = EstimateNetArea(job, trial);
+ if (cost >= baseline - 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);
+
+ /// 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.
+ public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet)
+ {
+ var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
+ var minimum = job.Options.MinimumSalvageDimension;
+ if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0) return area;
+ var work = DrawingJobMapper.CreatePlate(sheet.Stock).WorkArea();
+ var parts = job.Parts.ToDictionary(p => p.Id);
+ var boxes = sheet.Placements.Select(p =>
+ {
+ var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId]));
+ part.Rotate(p.Rotation);
+ part.Location = new OpenNest.Geometry.Vector(p.X, p.Y);
+ part.UpdateBounds();
+ return part.BoundingBox;
+ }).ToList();
+ var gap = sheet.Stock.PartSpacing;
+ var candidates = new[]
+ {
+ (work.Length, boxes.Min(b => b.Bottom) - work.Bottom - gap),
+ (work.Length, work.Top - boxes.Max(b => b.Top) - gap),
+ (boxes.Min(b => b.Left) - work.Left - gap, work.Width),
+ (work.Right - boxes.Max(b => b.Right) - gap, work.Width)
+ };
+ var salvage = candidates.Where(c => c.Item1 >= minimum && c.Item2 >= minimum)
+ .Select(c => c.Item1 * c.Item2).DefaultIfEmpty(0).Max();
+ return area - job.Options.SalvageRate * salvage;
+ }
+}
diff --git a/README.md b/README.md
index 3923e0c..28e3b99 100644
--- a/README.md
+++ b/README.md
@@ -218,6 +218,71 @@ OpenNest.sln
| **OpenNest.Benchmark** | Runs every registered whole-job nesting engine (`INestingEngine`) against a set of `.nest` files and scores them by material utilization, so competing engines — each owning its own multi-plate strategy — can be compared head-to-head. |
| **OpenNest.Tests** | 89 test files covering core geometry, fill strategies, splitting, bending, BOM import, post-processing, and the API. |
+### StockLadder whole-job baseline
+
+Select `new StockLadderNestingEngine().Solve(job)` or the whole-job registry's
+`StockLadder` engine (benchmark: `--engines StockLadder`). This does not switch the
+legacy desktop single-plate engine. Supply every allowed `NestPlateStock` explicitly;
+no stock sizes are invented. Stock quantity `null` means unlimited, `0` unavailable,
+and a positive quantity is finite inventory. The benchmark's `--sheet-sizes` pool
+uses unlimited quantities; use the job API for finite stock.
+
+```csharp
+var job = new NestJob(parts, callerStocks,
+ new NestJobOptions(maxPlates: 100, salvageRate: 0,
+ minimumSalvageDimension: 0));
+var result = new StockLadderNestingEngine().Solve(job, token: cancellationToken);
+```
+
+Construction orders by priority, then validated stock-fit scarcity, then part area,
+pins an anchor before fillers, and ranks candidate sheets by estimated net sheet
+area per placed part area. Repacking tries single-sheet replacements and adjacent
+pairs into one sheet, accepting only strictly lower estimated net area with exactly
+the same demand. Failed trials leave placements and finite stock accounting intact.
+
+Salvage is an **area estimate**, not price or certified recoverable material.
+`salvageRate` defaults to `0` (allowed range 0–1); `minimumSalvageDimension` defaults
+to `0`, which also disables credit. With both enabled, only the largest qualifying
+full-span edge rectangle outside placed bounding boxes plus part spacing is credited,
+within the usable work area; both dimensions must meet the minimum in job units.
+Holes/scraps are not credited. No cut-off toolpath, kerf, handling, or future-demand
+valuation is modeled. Benchmark ranking still uses gross material utilization.
+
+This is a tested deterministic heuristic baseline, **not an optimal or production-
+certified solver**. Conservative rectangular free-region hints and linear fills can
+miss concave interlocks and feasible layouts. Automatic rotation tries cardinal
+angles plus 5-degree increments below 180 degrees; fixed/range policies are honored.
+Repacking is bounded local search, not a global stock/demand search or fixed-point
+optimality proof. `NoPlacementFound` is not proof of impossibility. Cancellation is
+cooperative (the benchmark requests it after five minutes), not process isolation.
+Geometry acceptance remains strict, including open marks leaving closed material.
+
+Benchmark export example (use a separate output directory):
+
+```bash
+dotnet run --project OpenNest.Benchmark -- input.nest \
+ --engines StockLadder --sheet-sizes 48x96,48x120,48x144,60x96,60x120,60x144,72x96,72x120,72x144 \
+ --salvage-rate 0 --min-salvage-dimension 0 \
+ --output ./stockladder-output --csv ./stockladder.csv
+```
+
+`--output` writes validated layouts as `.nest` plus JSON containing status, stop
+reason, fulfillment, stock usage, poses, and gross/estimated net area. Valid but
+incomplete layouts may be exported: inspect status and fulfillment. Thrown/invalid
+runs do not export layouts. The console can exit zero despite a reported `CRASH`;
+inspect the report, not just the process exit code. Export does not certify cutting
+readiness and must not overwrite the source.
+
+**Known real-input blocker (no successful real-file result):**
+`/srv/shared/P260805-10_dxf/P260805-10.nest` requests 219 pieces from 69 drawings.
+With the nine caller-supplied sizes above, strict validation rejects drawing ID `57`,
+`4980 A01 PT75`: its open mark from `(-5.21875, -1.807287)` to
+`(-4.21875, -1.807287)` starts `0.0001` outside the perimeter's vertical edge at
+`x = -5.21865`. Error: `Geometry must contain usable closed edges: 57. Open geometry
+leaves the closed material region. (Parameter 'job')`. No snapping, clipping, or
+source geometry changes were made. Source SHA-256:
+`9e839fd51072587ec4f3173dc2f39ef1ea8ae460971889c2a3b91fa54b61091d`.
+
## Nesting Engines
OpenNest uses a pluggable engine architecture. The active engine can be selected at runtime.