Add tested caller-stock StockLadder baseline with strict geometry validation

This commit is contained in:
aj
2026-09-19 11:24:36 -04:00
parent 9b69c67572
commit ea4bd836cd
11 changed files with 759 additions and 11 deletions
+2 -2
View File
@@ -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.
/// </summary>
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));
}
}
}
+35 -4
View File
@@ -23,7 +23,8 @@ namespace OpenNest.Benchmark
/// <summary>Wall-clock budget for one engine solving one job.</summary>
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines)
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines,
double salvageRate = 0, double minimumSalvageDimension = 0, string outputDirectory = null)
{
var results = new List<JobResult>(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
+17 -1
View File
@@ -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 <value> Override part spacing for every job");
Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)");
Console.Error.WriteLine(" --csv <path> 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 <value> Both offcut dimensions must qualify; 0 disables credit");
Console.Error.WriteLine(" --output <directory> 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<string> EngineNames = new();
public string CsvPath;
public string OutputDirectory;
public double SalvageRate;
public double MinimumSalvageDimension;
}
}
@@ -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<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, 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<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);
}
}
+14 -1
View File
@@ -5,14 +5,27 @@ namespace OpenNest;
/// <summary>Immutable per-job options; selection never changes the legacy global registry.</summary>
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;
}
/// <summary>Fraction of eligible edge-offcut area credited by StockLadder (0..1).</summary>
public double SalvageRate { get; }
/// <summary>Both offcut dimensions must meet this caller-supplied minimum in job units.
/// Zero disables credit; scraps and holes are never credited.</summary>
public double MinimumSalvageDimension { get; }
public string PlacementStrategy { get; }
/// <summary>Maximum physical sheets to commit, or null for no explicit cap.</summary>
public int? MaxPlates { get; }
@@ -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<Entity>();
var marks = new List<Shape>();
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<Shape>());
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<Shape> holes)
{
const double chordTolerance = 0.00001;
var boundaries = new List<Shape> { 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<double> { 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<Shape> 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<Shape> { 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())
+1 -1
View File
@@ -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);
}
}
}
@@ -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"));
@@ -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;
/// <summary>Constrained-order linear fills in conservative rectangular free regions.
/// Regions are only search hints; every accepted pose passes the job geometry validator.</summary>
internal sealed class OrderedPlateNester : IPlateNester
{
private readonly Dictionary<string, Drawing> drawings = new(StringComparer.Ordinal);
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
var work = DrawingJobMapper.CreatePlate(request.Stock).WorkArea();
var poses = new List<NestJobPlacement>();
var obstacles = new List<Box>();
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<Part> 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<double> 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;
}
}
}
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace OpenNest;
/// <summary>
/// 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.
/// </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 = 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<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 = 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);
/// <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>
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;
}
}
+65
View File
@@ -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 01); `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.