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) <noreply@anthropic.com>
This commit is contained in:
165
OpenNest.Engine/PlateOptimizer.cs
Normal file
165
OpenNest.Engine/PlateOptimizer.cs
Normal file
@@ -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<NestItem> items,
|
||||
List<PlateOption> plateOptions,
|
||||
double salvageRate,
|
||||
Plate templatePlate,
|
||||
IProgress<NestProgress> 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<NestItem> items,
|
||||
double salvageRate,
|
||||
Plate templatePlate,
|
||||
IProgress<NestProgress> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
127
OpenNest.Tests/Engine/PlateOptimizerTests.cs
Normal file
127
OpenNest.Tests/Engine/PlateOptimizerTests.cs
Normal file
@@ -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<PlateOption>
|
||||
{
|
||||
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<NestItem>
|
||||
{
|
||||
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<PlateOption>
|
||||
{
|
||||
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<NestItem>
|
||||
{
|
||||
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<PlateOption>
|
||||
{
|
||||
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<NestItem>
|
||||
{
|
||||
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<PlateOption>
|
||||
{
|
||||
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<NestItem>
|
||||
{
|
||||
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<PlateOption>
|
||||
{
|
||||
new() { Width = 10, Length = 10, Cost = 50 },
|
||||
};
|
||||
|
||||
var templatePlate = new Plate(10, 10) { PartSpacing = 0 };
|
||||
var items = new List<NestItem>
|
||||
{
|
||||
new() { Drawing = MakeRectDrawing(20, 20), Quantity = 1 }
|
||||
};
|
||||
|
||||
var result = PlateOptimizer.Optimize(items, options, 0.0, templatePlate);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user