From ea4bd836cddaf56c64205bee5e433d59a5a098a0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 19 Sep 2026 11:24:36 -0400 Subject: [PATCH] Add tested caller-stock StockLadder baseline with strict geometry validation --- OpenNest.Benchmark/BenchmarkJob.cs | 4 +- OpenNest.Benchmark/BenchmarkRunner.cs | 39 +++- OpenNest.Benchmark/Program.cs | 18 +- .../Jobs/StockLadderTests.cs | 212 ++++++++++++++++++ OpenNest.Engine/Jobs/NestJobOptions.cs | 15 +- .../Jobs/NestJobPlacementValidator.cs | 121 +++++++++- OpenNest.Engine/Jobs/NestJobValidator.cs | 2 +- OpenNest.Engine/Jobs/NestingEngineRegistry.cs | 3 + .../Jobs/Placement/OrderedPlateNester.cs | 96 ++++++++ .../Jobs/StockLadderNestingEngine.cs | 195 ++++++++++++++++ README.md | 65 ++++++ 11 files changed, 759 insertions(+), 11 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/StockLadderTests.cs create mode 100644 OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs create mode 100644 OpenNest.Engine/Jobs/StockLadderNestingEngine.cs 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.