From 7380a433494e52f10fab64af940ddc10587ecde5 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 5 Apr 2026 00:31:36 -0400 Subject: [PATCH] feat: add PlateOptimizer with cost-aware plate size selection Tries each candidate plate size via the nesting engine, compares results by part count then net cost (accounting for salvage credit on remnant material), and returns the best option. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/PlateOptimizer.cs | 165 +++++++++++++++++++ OpenNest.Tests/Engine/PlateOptimizerTests.cs | 127 ++++++++++++++ 2 files changed, 292 insertions(+) create mode 100644 OpenNest.Engine/PlateOptimizer.cs create mode 100644 OpenNest.Tests/Engine/PlateOptimizerTests.cs diff --git a/OpenNest.Engine/PlateOptimizer.cs b/OpenNest.Engine/PlateOptimizer.cs new file mode 100644 index 0000000..ec53768 --- /dev/null +++ b/OpenNest.Engine/PlateOptimizer.cs @@ -0,0 +1,165 @@ +using OpenNest.Engine; +using OpenNest.Geometry; +using OpenNest.Math; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; + +namespace OpenNest +{ + public static class PlateOptimizer + { + public static PlateOptimizerResult Optimize( + List items, + List plateOptions, + double salvageRate, + Plate templatePlate, + IProgress progress = null, + CancellationToken token = default) + { + if (items == null || items.Count == 0 || plateOptions == null || plateOptions.Count == 0) + return null; + + // Find the minimum dimension needed to fit the largest part. + var minPartWidth = 0.0; + var minPartLength = 0.0; + foreach (var item in items) + { + if (item.Quantity <= 0) continue; + var bb = item.Drawing.Program.BoundingBox(); + var shortSide = System.Math.Min(bb.Width, bb.Length); + var longSide = System.Math.Max(bb.Width, bb.Length); + if (shortSide > minPartWidth) minPartWidth = shortSide; + if (longSide > minPartLength) minPartLength = longSide; + } + + // Sort candidates by cost ascending — try cheapest first. + var candidates = plateOptions + .Where(o => FitsPart(o, minPartWidth, minPartLength, templatePlate.EdgeSpacing)) + .OrderBy(o => o.Cost) + .ToList(); + + if (candidates.Count == 0) + return null; + + PlateOptimizerResult best = null; + + foreach (var option in candidates) + { + if (token.IsCancellationRequested) + break; + + var result = TryPlateSize(option, items, salvageRate, templatePlate, progress, token); + if (result == null) + continue; + + if (IsBetter(result, best)) + best = result; + + // Early exit: when salvage is zero, cheapest plate that fits everything wins. + // With salvage > 0, larger plates may have lower net cost, so keep searching. + if (salvageRate <= 0) + { + var allPlaced = items.All(i => i.Quantity <= 0 || + result.Parts.Count(p => p.BaseDrawing.Name == i.Drawing.Name) >= i.Quantity); + if (allPlaced) + { + Debug.WriteLine($"[PlateOptimizer] Early exit: {option.Width}x{option.Length} placed all items"); + break; + } + } + } + + return best; + } + + private static bool FitsPart(PlateOption option, double minWidth, double minLength, Spacing edgeSpacing) + { + var workW = option.Width - edgeSpacing.Left - edgeSpacing.Right; + var workL = option.Length - edgeSpacing.Top - edgeSpacing.Bottom; + + // Part fits in either orientation. + var fitsNormal = workW >= minWidth - Tolerance.Epsilon && workL >= minLength - Tolerance.Epsilon; + var fitsRotated = workW >= minLength - Tolerance.Epsilon && workL >= minWidth - Tolerance.Epsilon; + return fitsNormal || fitsRotated; + } + + private static PlateOptimizerResult TryPlateSize( + PlateOption option, + List items, + double salvageRate, + Plate templatePlate, + IProgress progress, + CancellationToken token) + { + // Create a temporary plate with candidate size + settings from template. + var tempPlate = new Plate(option.Width, option.Length) + { + PartSpacing = templatePlate.PartSpacing, + EdgeSpacing = new Spacing + { + Left = templatePlate.EdgeSpacing.Left, + Right = templatePlate.EdgeSpacing.Right, + Top = templatePlate.EdgeSpacing.Top, + Bottom = templatePlate.EdgeSpacing.Bottom, + }, + }; + + // Clone items so the dry run doesn't mutate originals. + var clonedItems = items.Select(i => new NestItem + { + Drawing = i.Drawing, // share Drawing reference for BestFitCache compatibility + Priority = i.Priority, + Quantity = i.Quantity, + StepAngle = i.StepAngle, + RotationStart = i.RotationStart, + RotationEnd = i.RotationEnd, + }).ToList(); + + var engine = NestEngineRegistry.Create(tempPlate); + var parts = engine.Nest(clonedItems, progress, token); + + if (parts == null || parts.Count == 0) + return null; + + var workArea = tempPlate.WorkArea(); + var plateArea = workArea.Width * workArea.Length; + var partsArea = 0.0; + foreach (var part in parts) + partsArea += part.BoundingBox.Area(); + + var remnantArea = plateArea - partsArea; + var costPerSqUnit = option.Cost / option.Area; + var netCost = option.Cost - (remnantArea * costPerSqUnit * salvageRate); + + Debug.WriteLine($"[PlateOptimizer] {option.Width}x{option.Length} ${option.Cost}: " + + $"{parts.Count} parts, util={partsArea / plateArea:P1}, net=${netCost:F2}"); + + return new PlateOptimizerResult + { + Parts = parts, + ChosenSize = option, + NetCost = netCost, + Utilization = plateArea > 0 ? partsArea / plateArea : 0, + }; + } + + private static bool IsBetter(PlateOptimizerResult candidate, PlateOptimizerResult current) + { + if (current == null) return true; + + // 1. More parts placed is always better. + if (candidate.Parts.Count != current.Parts.Count) + return candidate.Parts.Count > current.Parts.Count; + + // 2. Lower net cost. + if (!candidate.NetCost.IsEqualTo(current.NetCost)) + return candidate.NetCost < current.NetCost; + + // 3. Smaller plate area as tiebreak. + return candidate.ChosenSize.Area < current.ChosenSize.Area; + } + } +} diff --git a/OpenNest.Tests/Engine/PlateOptimizerTests.cs b/OpenNest.Tests/Engine/PlateOptimizerTests.cs new file mode 100644 index 0000000..1f00367 --- /dev/null +++ b/OpenNest.Tests/Engine/PlateOptimizerTests.cs @@ -0,0 +1,127 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests.Engine; + +public class PlateOptimizerTests +{ + private static Drawing MakeRectDrawing(double w, double h, string name = "rect") + { + var pgm = new OpenNest.CNC.Program(); + pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0))); + return new Drawing(name, pgm); + } + + [Fact] + public void PicksCheapestPlateThatFitsParts() + { + var options = new List + { + new() { Width = 20, Length = 20, Cost = 100 }, + new() { Width = 40, Length = 40, Cost = 400 }, + }; + + var templatePlate = new Plate(40, 40) { PartSpacing = 0 }; + var items = new List + { + new() { Drawing = MakeRectDrawing(10, 10), Quantity = 1 } + }; + + var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate); + + Assert.NotNull(result); + Assert.Equal(20, result.ChosenSize.Width); + Assert.True(result.Parts.Count >= 1); + } + + [Fact] + public void PrefersMorePartsOverCheaperPlate() + { + var options = new List + { + new() { Width = 12, Length = 12, Cost = 50 }, + new() { Width = 24, Length = 12, Cost = 100 }, + }; + + var templatePlate = new Plate(24, 12) { PartSpacing = 0 }; + var items = new List + { + new() { Drawing = MakeRectDrawing(10, 10), Quantity = 2 } + }; + + var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate); + + Assert.NotNull(result); + Assert.Equal(24, result.ChosenSize.Width); + Assert.Equal(2, result.Parts.Count); + } + + [Fact] + public void SalvageRateReducesNetCost() + { + // Small: 20x20=400sqin, cost $400. Part=10x10=100sqin. Remnant=300. + // Net = 400 - 300*(400/400)*1.0 = 400-300 = 100 + // Large: 40x40=1600sqin, cost $800. Part=10x10=100sqin. Remnant=1500. + // Net = 800 - 1500*(800/1600)*1.0 = 800-750 = 50 + var options = new List + { + new() { Width = 20, Length = 20, Cost = 400 }, + new() { Width = 40, Length = 40, Cost = 800 }, + }; + + var templatePlate = new Plate(40, 40) { PartSpacing = 0 }; + templatePlate.EdgeSpacing = new Spacing(); + var items = new List + { + new() { Drawing = MakeRectDrawing(10, 10), Quantity = 1 } + }; + + var result = PlateOptimizer.Optimize(items, options, 1.0, templatePlate); + + Assert.NotNull(result); + Assert.Equal(40, result.ChosenSize.Width); + } + + [Fact] + public void SkipsPlatesThatAreTooSmall() + { + var options = new List + { + new() { Width = 20, Length = 20, Cost = 100 }, + new() { Width = 40, Length = 40, Cost = 400 }, + }; + + var templatePlate = new Plate(40, 40) { PartSpacing = 0 }; + var items = new List + { + new() { Drawing = MakeRectDrawing(30, 30), Quantity = 1 } + }; + + var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate); + + Assert.NotNull(result); + Assert.Equal(40, result.ChosenSize.Width); + } + + [Fact] + public void ReturnsNullWhenNoPlatesFit() + { + var options = new List + { + new() { Width = 10, Length = 10, Cost = 50 }, + }; + + var templatePlate = new Plate(10, 10) { PartSpacing = 0 }; + var items = new List + { + new() { Drawing = MakeRectDrawing(20, 20), Quantity = 1 } + }; + + var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate); + + Assert.Null(result); + } +}