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); + } +}