refactor(engine): remove legacy multi-plate auto-nest orchestration

Desktop Auto Nest, Console --autonest, MCP autonest_plate and the API
NestRunner all run through NestPipeline, so nothing calls the old
orchestration any more.

Delete MultiPlateNester (with MultiPlateNestOptions, MultiPlateResult,
PlateResult, PartClass and PartSortOrder), PlateOptimizer and
PlateOptimizerResult, plus their tests. The explicit-strategy contract
those tests checked now lives at PlateFillService.ResolveStrategy, which
keeps its null-means-Default, canonical-name and unknown-name tests.
CreateFiller loses its internal visibility, which only the deleted
orchestrators used.

This breaks source and binary compatibility for external callers of the
removed types; whole-job callers use NestPipeline.Run or INestingEngine.
This commit is contained in:
aj committed 2026-09-30 21:07:32 -04:00
1 parent 5e9eaf8a4c
commit c55e375cf3
11 files changed
+22 -1799

No files matched your search

-12
View File
@@ -1,12 +0,0 @@
using System.Collections.Generic;
namespace OpenNest
{
public class PlateOptimizerResult
{
public List<Part> Parts { get; set; } = new();
public PlateOption ChosenSize { get; set; }
public double NetCost { get; set; }
public double Utilization { get; set; }
}
}
@@ -122,11 +122,8 @@ public static class PlateFillService
);
}
/// <summary>
/// Builds the filler for an optional strategy (null/empty = Default). Internal so the
/// engine-side multi-plate orchestrators share one resolution/rejection contract.
/// </summary>
internal static PlateFillerBase CreateFiller(string strategy, Plate plate)
/// <summary>Builds the filler for an optional strategy (null/empty = Default).</summary>
private static PlateFillerBase CreateFiller(string strategy, Plate plate)
{
ArgumentNullException.ThrowIfNull(plate);
return ResolveStrategy(strategy) switch
@@ -142,8 +139,8 @@ public static class PlateFillService
private static PlateFillerBase RequireFiller(string strategy, Plate plate)
{
ArgumentNullException.ThrowIfNull(strategy);
// An explicit empty string is an unknown strategy, not the orchestrator's
// null-means-Default defaulting; only the orchestrator boundary may default.
// An explicit empty string is an unknown strategy; only ResolveStrategy(string),
// used for caller selections, maps null/empty to Default.
ResolveStrategy(strategy, allowEmpty: false);
return CreateFiller(strategy, plate);
}
-718
View File
@@ -1,718 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.Jobs.Placement.Fillers;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine
{
public enum PartClass
{
Large,
Medium,
Small,
}
public class MultiPlateNester
{
private readonly Plate _template;
private readonly List<PlateOption> _plateOptions;
private readonly List<PlateOption> _sortedOptions;
private readonly double _salvageRate;
private readonly double _minRemnantSize;
private readonly List<PlateResult> _platePool;
private readonly IProgress<NestProgress> _progress;
private readonly CancellationToken _token;
private readonly MultiPlateNestOptions _options;
private readonly string _strategy;
private bool HasPlateOptions => _plateOptions != null && _plateOptions.Count > 0;
private MultiPlateNester(
MultiPlateNestOptions options,
List<Plate> existingPlates,
IProgress<NestProgress> progress,
CancellationToken token
)
{
_options = options;
_template = options.Template;
_plateOptions = options.PlateOptions;
_sortedOptions = options.PlateOptions?.OrderBy(o => o.Cost).ToList();
_salvageRate = options.SalvageRate;
_minRemnantSize = options.MinRemnantSize;
_platePool = InitializePlatePool(existingPlates);
_progress = progress;
_token = token;
_strategy = PlateFillService.ResolveStrategy(options.Strategy);
}
private PlateFillerBase CreateFiller(Plate plate) =>
PlateFillService.CreateFiller(_strategy, plate);
// --- Static Utility Methods ---
public static bool FitsBounds(Box container, Box part)
{
var fitsNormal =
container.Width >= part.Width - Tolerance.Epsilon
&& container.Length >= part.Length - Tolerance.Epsilon;
var fitsRotated =
container.Width >= part.Length - Tolerance.Epsilon
&& container.Length >= part.Width - Tolerance.Epsilon;
return fitsNormal || fitsRotated;
}
public static List<NestItem> SortItems(List<NestItem> items, PartSortOrder sortOrder)
{
var withBounds = items
.Select(i => (Item: i, Bounds: i.Drawing.Program.BoundingBox()))
.ToList();
switch (sortOrder)
{
case PartSortOrder.BoundingBoxArea:
return withBounds
.OrderByDescending(x => x.Bounds.Width * x.Bounds.Length)
.Select(x => x.Item)
.ToList();
case PartSortOrder.Size:
return withBounds
.OrderByDescending(x => System.Math.Max(x.Bounds.Width, x.Bounds.Length))
.Select(x => x.Item)
.ToList();
default:
return items.ToList();
}
}
public static PartClass Classify(Box partBounds, Box workArea)
{
var halfWidth = workArea.Width / 2.0;
var halfLength = workArea.Length / 2.0;
if (partBounds.Width > halfWidth || partBounds.Length > halfLength)
return PartClass.Large;
var workAreaArea = workArea.Width * workArea.Length;
var partArea = partBounds.Width * partBounds.Length;
if (partArea > workAreaArea / 9.0)
return PartClass.Medium;
return PartClass.Small;
}
public static bool IsScrapRemnant(Box remnant, double minRemnantSize)
{
return remnant.Width < minRemnantSize && remnant.Length < minRemnantSize;
}
public static List<Box> FindRemnants(Plate plate, double minRemnantSize, bool scrapOnly)
{
var remnants = RemnantFinder.FromPlate(plate).FindRemnants();
return remnants.Where(r => IsScrapRemnant(r, minRemnantSize) == scrapOnly).ToList();
}
public struct UpgradeDecision
{
public bool ShouldUpgrade;
public double UpgradeCost;
public double NewPlateCost;
}
public static Plate CreatePlate(Plate template, List<PlateOption> options, Box minBounds)
{
var plate = new Plate(template.Size)
{
PartSpacing = template.PartSpacing,
Quadrant = template.Quadrant,
};
plate.EdgeSpacing = new Spacing
{
Left = template.EdgeSpacing.Left,
Right = template.EdgeSpacing.Right,
Top = template.EdgeSpacing.Top,
Bottom = template.EdgeSpacing.Bottom,
};
if (options == null || options.Count == 0 || minBounds == null)
return plate;
var sorted = options.OrderBy(o => o.Cost).ToList();
foreach (var option in sorted)
{
if (FitsBounds(OptionWorkArea(option, template), minBounds))
{
plate.Size = new Size(option.Width, option.Length);
return plate;
}
}
return plate;
}
public static UpgradeDecision EvaluateUpgradeVsNew(
PlateOption currentSize,
PlateOption upgradeSize,
PlateOption newPlateSize,
double salvageRate,
double estimatedNewPlateUtilization
)
{
var upgradeCost = upgradeSize.Cost - currentSize.Cost;
var newPlateCost = newPlateSize.Cost;
var remnantFraction = 1.0 - estimatedNewPlateUtilization;
var salvageCredit = remnantFraction * newPlateSize.Cost * salvageRate;
var netNewCost = newPlateCost - salvageCredit;
return new UpgradeDecision
{
ShouldUpgrade = upgradeCost <= netNewCost,
UpgradeCost = upgradeCost,
NewPlateCost = netNewCost,
};
}
// --- Main Entry Point ---
public static MultiPlateResult Nest(
List<NestItem> items,
MultiPlateNestOptions options,
List<Plate> existingPlates = null,
IProgress<NestProgress> progress = null,
CancellationToken token = default
)
{
var nester = new MultiPlateNester(options, existingPlates, progress, token);
return nester.Run(items, options.SortOrder, options.AllowPlateCreation);
}
// --- Private Helpers ---
private static Box OptionWorkArea(PlateOption option, Plate template)
{
var w = option.Width - template.EdgeSpacing.Left - template.EdgeSpacing.Right;
var h = option.Length - template.EdgeSpacing.Top - template.EdgeSpacing.Bottom;
return new Box(0, 0, w, h);
}
private static double ScoreZone(Box zone, Box partBounds)
{
if (!FitsBounds(zone, partBounds))
return -1;
var cols = (int)(zone.Width / partBounds.Width);
var rows = (int)(zone.Length / partBounds.Length);
var colsR = (int)(zone.Width / partBounds.Length);
var rowsR = (int)(zone.Length / partBounds.Width);
var estimatedCount = System.Math.Max(cols * rows, colsR * rowsR);
var utilization = (estimatedCount * partBounds.Width * partBounds.Length) / zone.Area();
var zoneAspect = zone.Width / zone.Length;
var partAspect = partBounds.Width / partBounds.Length;
var aspectMatch =
System.Math.Min(zoneAspect, partAspect) / System.Math.Max(zoneAspect, partAspect);
return utilization * 0.7 + aspectMatch * 0.3;
}
private static void DecrementQuantity(NestItem item, int placed)
{
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
private int FillAndPlace(PlateResult pr, Box zone, NestItem item)
{
var engine = CreateFiller(pr.Plate);
var clonedItem = CloneItem(item);
var parts = engine.Fill(clonedItem, zone, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
DecrementQuantity(item, parts.Count);
}
return parts.Count;
}
private PlateResult CreateNewPlateResult(Plate plate)
{
var pr = new PlateResult { Plate = plate, IsNew = true };
if (HasPlateOptions)
{
pr.ChosenSize = _plateOptions.FirstOrDefault(o =>
o.Width.IsEqualTo(plate.Size.Width) && o.Length.IsEqualTo(plate.Size.Length)
);
}
return pr;
}
private static NestItem CloneItem(NestItem item)
{
return new NestItem
{
Drawing = item.Drawing,
Priority = item.Priority,
Quantity = item.Quantity,
StepAngle = item.StepAngle,
RotationStart = item.RotationStart,
RotationEnd = item.RotationEnd,
};
}
private static List<PlateResult> InitializePlatePool(List<Plate> existingPlates)
{
var pool = new List<PlateResult>();
if (existingPlates != null)
{
foreach (var plate in existingPlates)
pool.Add(new PlateResult { Plate = plate, IsNew = false });
}
return pool;
}
private bool TryWithUpgradedSize(
PlateResult pr,
PlateOption upgradeOption,
Func<List<Box>, bool> tryFill
)
{
var oldSize = pr.Plate.Size;
var oldChosenSize = pr.ChosenSize;
pr.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length);
pr.ChosenSize = upgradeOption;
var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
if (remnants.Count > 0 && tryFill(remnants))
return true;
pr.Plate.Size = oldSize;
pr.ChosenSize = oldChosenSize;
return false;
}
private PlateOption FindSmallestFittingOption(Box partBounds)
{
return _sortedOptions?.FirstOrDefault(o =>
FitsBounds(OptionWorkArea(o, _template), partBounds)
);
}
// --- Orchestration ---
private MultiPlateResult Run(
List<NestItem> items,
PartSortOrder sortOrder,
bool allowPlateCreation
)
{
var result = new MultiPlateResult();
if (items == null || items.Count == 0)
return result;
var sorted = SortItems(items.Where(i => i.Quantity > 0).ToList(), sortOrder);
foreach (var item in sorted)
{
if (_token.IsCancellationRequested || item.Quantity <= 0)
continue;
var bb = item.Drawing.Program.BoundingBox();
TryPlaceOnExistingPlates(item, bb);
var templateClass = Classify(bb, _template.WorkArea());
if (item.Quantity > 0 && allowPlateCreation && templateClass != PartClass.Small)
{
PlaceOnNewPlates(item, bb);
if (item.Quantity > 0 && HasPlateOptions)
TryUpgradeOrNewPlate(item, bb);
}
}
var leftovers = sorted.Where(i => i.Quantity > 0).ToList();
if (leftovers.Count > 0 && allowPlateCreation && !_token.IsCancellationRequested)
{
PackIntoExistingRemnants(leftovers);
CreateSharedPlates(leftovers);
}
if (HasPlateOptions && !_token.IsCancellationRequested)
TryConsolidateTailPlates();
foreach (var item in sorted.Where(i => i.Quantity > 0))
result.UnplacedItems.Add(item);
result.Plates.AddRange(_platePool.Where(p => p.Parts.Count > 0 || p.IsNew));
return result;
}
private void PackIntoExistingRemnants(List<NestItem> leftovers)
{
foreach (var pr in _platePool)
{
if (_token.IsCancellationRequested)
break;
var anyPlaced = true;
while (anyPlaced && !_token.IsCancellationRequested)
{
anyPlaced = false;
var remaining = leftovers.Where(i => i.Quantity > 0).ToList();
if (remaining.Count == 0)
break;
var remnants = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
if (remnants.Count == 0)
break;
var engine = CreateFiller(pr.Plate);
foreach (var remnant in remnants)
{
remaining = leftovers.Where(i => i.Quantity > 0).ToList();
if (remaining.Count == 0)
break;
var cloned = remaining.Select(CloneItem).ToList();
var parts = engine.PackArea(remnant, cloned, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
anyPlaced = true;
foreach (var item in remaining)
{
var placed = parts.Count(p => p.BaseDrawing == item.Drawing);
DecrementQuantity(item, placed);
}
}
}
}
}
}
private void CreateSharedPlates(List<NestItem> leftovers)
{
leftovers.RemoveAll(i => i.Quantity <= 0);
while (leftovers.Count > 0 && !_token.IsCancellationRequested)
{
var plate = CreatePlate(_template, _plateOptions, null);
var pr = CreateNewPlateResult(plate);
var placedAny = false;
foreach (var item in leftovers)
{
if (item.Quantity <= 0 || _token.IsCancellationRequested)
continue;
var remnants = !placedAny
? new List<Box> { plate.WorkArea() }
: RemnantFinder.FromPlate(plate).FindRemnants();
if (remnants.Count == 0)
break;
var engine = CreateFiller(plate);
foreach (var remnant in remnants)
{
if (item.Quantity <= 0)
break;
var clonedItem = CloneItem(item);
var parts = engine.Fill(clonedItem, remnant, _progress, _token);
if (parts.Count > 0)
{
pr.AddParts(parts);
DecrementQuantity(item, parts.Count);
placedAny = true;
}
}
}
if (!placedAny)
break;
_platePool.Add(pr);
leftovers.RemoveAll(i => i.Quantity <= 0);
}
}
private bool TryPlaceOnExistingPlates(NestItem item, Box partBounds)
{
var anyPlaced = false;
var remnantCache = new Dictionary<PlateResult, List<Box>>();
PlateResult lastModified = null;
while (item.Quantity > 0 && !_token.IsCancellationRequested)
{
PlateResult bestPlate = null;
Box bestZone = null;
var bestScore = double.MinValue;
foreach (var pr in _platePool)
{
if (_token.IsCancellationRequested)
break;
if (pr == lastModified || !remnantCache.ContainsKey(pr))
{
var workArea = pr.Plate.WorkArea();
var classification = Classify(partBounds, workArea);
remnantCache[pr] =
classification == PartClass.Small
? FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: true)
: FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: false);
}
foreach (var zone in remnantCache[pr])
{
var score = ScoreZone(zone, partBounds);
if (score > bestScore)
{
bestPlate = pr;
bestZone = zone;
bestScore = score;
}
}
}
if (bestPlate == null || bestZone == null)
break;
if (FillAndPlace(bestPlate, bestZone, item) == 0)
break;
lastModified = bestPlate;
anyPlaced = true;
}
return anyPlaced;
}
private bool PlaceOnNewPlates(NestItem item, Box partBounds)
{
var anyPlaced = false;
while (item.Quantity > 0 && !_token.IsCancellationRequested)
{
var plate = CreatePlate(_template, _plateOptions, partBounds);
var workArea = plate.WorkArea();
if (!FitsBounds(workArea, partBounds))
break;
var pr = CreateNewPlateResult(plate);
if (FillAndPlace(pr, workArea, item) == 0)
break;
_platePool.Add(pr);
anyPlaced = true;
}
return anyPlaced;
}
private bool TryUpgradeOrNewPlate(NestItem item, Box partBounds)
{
if (!HasPlateOptions)
return false;
foreach (var pr in _platePool.Where(p => p.IsNew && p.ChosenSize != null))
{
var currentOption = pr.ChosenSize;
var currentIdx = _sortedOptions.FindIndex(o =>
o.Width.IsEqualTo(currentOption.Width)
&& o.Length.IsEqualTo(currentOption.Length)
);
if (currentIdx < 0 || currentIdx >= _sortedOptions.Count - 1)
continue;
for (var i = currentIdx + 1; i < _sortedOptions.Count; i++)
{
var upgradeOption = _sortedOptions[i];
if (
upgradeOption.Width < currentOption.Width - Tolerance.Epsilon
|| upgradeOption.Length < currentOption.Length - Tolerance.Epsilon
)
continue;
var smallestNew = FindSmallestFittingOption(partBounds);
if (smallestNew == null)
continue;
var utilEst = pr.Plate.Utilization();
var decision = EvaluateUpgradeVsNew(
currentOption,
upgradeOption,
smallestNew,
_salvageRate,
utilEst
);
if (decision.ShouldUpgrade)
{
var placed = TryWithUpgradedSize(
pr,
upgradeOption,
remnants =>
{
foreach (var remnant in remnants)
{
if (FillAndPlace(pr, remnant, item) > 0)
return true;
}
return false;
}
);
if (placed)
return true;
}
}
}
return false;
}
private void TryConsolidateTailPlates()
{
var consolidated = true;
while (consolidated)
{
consolidated = false;
var activePlates = _platePool.Where(p => p.Parts.Count > 0 && p.IsNew).ToList();
if (activePlates.Count < 2)
return;
var donors = activePlates.OrderBy(p => p.Plate.Utilization()).ToList();
foreach (var donor in donors)
{
if (donor.Parts.Count == 0)
continue;
var donorParts = donor.Parts.ToList();
var absorbed = false;
foreach (var target in activePlates)
{
if (target == donor || target.ChosenSize == null || target.Parts.Count == 0)
continue;
var currentOption = target.ChosenSize;
foreach (
var upgradeOption in _sortedOptions.Where(o =>
o.Width >= currentOption.Width - Tolerance.Epsilon
&& o.Length >= currentOption.Length - Tolerance.Epsilon
&& (
o.Width > currentOption.Width + Tolerance.Epsilon
|| o.Length > currentOption.Length + Tolerance.Epsilon
)
)
)
{
absorbed = TryWithUpgradedSize(
target,
upgradeOption,
remnants =>
{
var engine = CreateFiller(target.Plate);
var tempItems = donorParts
.GroupBy(p => p.BaseDrawing)
.Select(g => new NestItem
{
Drawing = g.Key,
Quantity = g.Count(),
})
.ToList();
var totalPlaced = new List<Part>();
foreach (var remnant in remnants)
{
var placed = engine.PackArea(
remnant,
tempItems,
_progress,
_token
);
totalPlaced.AddRange(placed);
foreach (var ti in tempItems)
{
var count = placed.Count(p =>
p.BaseDrawing == ti.Drawing
);
ti.Quantity = System.Math.Max(0, ti.Quantity - count);
}
if (tempItems.All(ti => ti.Quantity <= 0))
break;
}
if (totalPlaced.Count >= donorParts.Count)
{
target.AddParts(totalPlaced);
foreach (var p in donorParts)
donor.Plate.Parts.Remove(p);
donor.Parts.Clear();
_platePool.Remove(donor);
return true;
}
return false;
}
);
if (absorbed)
break;
}
if (absorbed)
break;
}
if (absorbed)
{
consolidated = true;
break;
}
}
}
}
}
}
-42
View File
@@ -1,42 +0,0 @@
using System.Collections.Generic;
namespace OpenNest.Engine
{
public class MultiPlateNestOptions
{
public Plate Template { get; set; }
public List<PlateOption> PlateOptions { get; set; }
public double SalvageRate { get; set; } = 0.5;
public PartSortOrder SortOrder { get; set; } = PartSortOrder.BoundingBoxArea;
public double MinRemnantSize { get; set; } = 12.0;
public bool AllowPlateCreation { get; set; } = true;
/// <summary>
/// Explicit placement strategy for every single-plate fill/pack this run performs
/// ("Default", "Strip", "Vertical Remnant", "Horizontal Remnant"). Null or empty means
/// "Default"; unknown names are rejected up front. This replaces reading the
/// process-global engine registry.
/// </summary>
public string Strategy { get; set; }
}
public class MultiPlateResult
{
public List<PlateResult> Plates { get; set; } = new();
public List<NestItem> UnplacedItems { get; set; } = new();
}
public class PlateResult
{
public Plate Plate { get; set; }
public List<Part> Parts { get; set; } = new();
public PlateOption ChosenSize { get; set; }
public bool IsNew { get; set; }
public void AddParts(IList<Part> parts)
{
Plate.Parts.AddRange(parts);
Parts.AddRange(parts);
}
}
}
-8
View File
@@ -1,8 +0,0 @@
namespace OpenNest.Engine
{
public enum PartSortOrder
{
BoundingBoxArea,
Size,
}
}
-235
View File
@@ -1,235 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Engine
{
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,
string strategy = null
)
{
if (
items == null
|| items.Count == 0
|| plateOptions == null
|| plateOptions.Count == 0
)
return null;
// Explicit strategy at the top-level boundary (null/empty = Default, unknown = throw);
// the size search never consults the process-global engine registry.
var resolvedStrategy = PlateFillService.ResolveStrategy(strategy);
// Find the minimum dimension needed to fit the largest part,
// skipping items that are too large for every plate option.
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 (
!plateOptions.Any(o =>
FitsPart(o, shortSide, longSide, templatePlate.EdgeSpacing)
)
)
{
Debug.WriteLine(
$"[PlateOptimizer] Skipping oversized item '{item.Drawing.Name}' "
+ $"({shortSide:F1}x{longSide:F1}) — does not fit any plate option"
);
continue;
}
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;
// Pre-compute best fits for all candidate plate sizes at once.
// This runs the expensive GPU evaluation once on the largest plate
// and filters the results for each smaller size.
var plateSizes = candidates.Select(o => (Width: o.Length, Height: o.Width)).ToList();
foreach (var item in items)
{
if (item.Quantity <= 0)
continue;
BestFitCache.ComputeForSizes(item.Drawing, templatePlate.PartSpacing, plateSizes);
}
PlateOptimizerResult best = null;
foreach (var option in candidates)
{
if (token.IsCancellationRequested)
break;
var result = TryPlateSize(
option,
items,
salvageRate,
templatePlate,
progress,
token,
resolvedStrategy
);
if (result == null)
continue;
if (IsBetter(result, best))
best = result;
// Early exit: when all items fit, larger plates can only have
// worse utilization and higher cost. With salvage < 100%, the
// remnant credit never offsets the extra plate cost, so skip.
if (salvageRate < 1.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,
string strategy
)
{
// 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 parts = PlateFillService.Nest(strategy, tempPlate, 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. Higher utilization (tighter density) as tiebreak.
return candidate.Utilization > current.Utilization;
}
}
}
+1 -1
View File
@@ -364,7 +364,7 @@ namespace OpenNest.IO
pd.EdgeSpacing.Top
);
// Plate optimizer settings
// Stock options: sheet sizes and salvage rate
nest.SalvageRate = dto.SalvageRate;
if (dto.PlateOptions != null)
{
@@ -6,8 +6,8 @@ using OpenNest.Geometry;
namespace OpenNest.Tests.Engine;
/// <summary>
/// Phase 3.2: MultiPlateNester and PlateOptimizer take an explicit placement strategy at their
/// top-level call boundary instead of consulting process-global selection state.
/// Callers name the placement strategy explicitly at the <see cref="PlateFillService"/>
/// boundary instead of consulting process-global selection state.
/// </summary>
public class ExplicitPlacementStrategyTests
{
@@ -22,106 +22,29 @@ public class ExplicitPlacementStrategyTests
return new Drawing(name, pgm);
}
private static NestItem MakeItem(string name, double w, double h, int qty) =>
new() { Drawing = MakeRectDrawing(w, h, name), Quantity = qty };
private static MultiPlateNestOptions MakeOptions()
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ResolveStrategy_UnsetMeansDefault(string strategy)
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing();
return new MultiPlateNestOptions { Template = template };
Assert.Equal("Default", PlateFillService.ResolveStrategy(strategy));
}
[Fact]
public void MultiPlateNester_UnsetStrategy_MatchesExplicitDefault()
[Theory]
[InlineData("strip", "Strip")]
[InlineData("VERTICAL REMNANT", "Vertical Remnant")]
public void ResolveStrategy_ReturnsCanonicalBuiltInName(string strategy, string expected)
{
var unsetOptions = MakeOptions();
var explicitOptions = MakeOptions();
explicitOptions.Strategy = "Default";
Assert.Null(unsetOptions.Strategy);
var unsetResult = MultiPlateNester.Nest(
new List<NestItem> { MakeItem("a", 20, 10, 3), MakeItem("b", 12, 8, 4) },
unsetOptions
);
var explicitResult = MultiPlateNester.Nest(
new List<NestItem> { MakeItem("a", 20, 10, 3), MakeItem("b", 12, 8, 4) },
explicitOptions
);
Assert.NotEmpty(unsetResult.Plates);
Assert.Equal(
unsetResult.Plates.Sum(p => p.Parts.Count),
explicitResult.Plates.Sum(p => p.Parts.Count)
);
Assert.Equal(expected, PlateFillService.ResolveStrategy(strategy));
}
[Theory]
[InlineData("Mystery Engine")]
[InlineData("StockLadder")]
public void MultiPlateNester_RejectsUnknownStrategy(string strategy)
public void ResolveStrategy_RejectsUnknownStrategy(string strategy)
{
var options = MakeOptions();
options.Strategy = strategy;
Assert.Throws<NotSupportedException>(() =>
MultiPlateNester.Nest(new List<NestItem> { MakeItem("a", 20, 10, 1) }, options)
);
}
[Fact]
public void MultiPlateNester_HonorsExplicitStripStrategy()
{
var options = MakeOptions();
options.Strategy = "Strip";
var result = MultiPlateNester.Nest(
new List<NestItem> { MakeItem("a", 20, 10, 3), MakeItem("b", 12, 8, 4) },
options
);
Assert.NotEmpty(result.Plates);
Assert.Equal(0, result.UnplacedItems.Count);
}
[Fact]
public void PlateOptimizer_UnsetStrategy_MatchesExplicitDefault()
{
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> { MakeItem("rect", 10, 10, 1) };
var defaulted = PlateOptimizer.Optimize(items, options, 0.0, templatePlate);
var explicitDefault = PlateOptimizer.Optimize(
items,
options,
0.0,
templatePlate,
strategy: "Default"
);
Assert.NotNull(defaulted);
Assert.NotNull(explicitDefault);
Assert.Equal(defaulted.ChosenSize.Width, explicitDefault.ChosenSize.Width);
Assert.Equal(defaulted.Parts.Count, explicitDefault.Parts.Count);
}
[Theory]
[InlineData("Mystery Engine")]
[InlineData("StockLadder")]
public void PlateOptimizer_RejectsUnknownStrategy(string strategy)
{
var options = new List<PlateOption> { new() { Width = 20, Length = 20, Cost = 100 } };
var templatePlate = new Plate(40, 40) { PartSpacing = 0 };
var items = new List<NestItem> { MakeItem("rect", 10, 10, 1) };
Assert.Throws<NotSupportedException>(() =>
PlateOptimizer.Optimize(items, options, 0.0, templatePlate, strategy: strategy)
);
Assert.Throws<NotSupportedException>(() => PlateFillService.ResolveStrategy(strategy));
}
[Fact]
@@ -1,511 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
using OpenNest.IO;
using Xunit;
using Xunit.Abstractions;
using OpenNest.Engine;
namespace OpenNest.Tests.Engine;
public class MultiPlateNesterTests
{
private readonly ITestOutputHelper _output;
public MultiPlateNesterTests(ITestOutputHelper output)
{
_output = output;
}
private static Drawing MakeDrawing(string name, double width, double length)
{
var program = new OpenNest.CNC.Program();
program.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0)));
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(width, 0)));
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(width, length)));
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, length)));
program.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0)));
var drawing = new Drawing(name, program);
drawing.UpdateArea();
return drawing;
}
private static NestItem MakeItem(string name, double width, double length, int qty = 1)
{
return new NestItem { Drawing = MakeDrawing(name, width, length), Quantity = qty };
}
[Fact]
public void SortByBoundingBoxArea_OrdersLargestFirst()
{
var items = new List<NestItem>
{
MakeItem("small", 10, 10),
MakeItem("large", 40, 60),
MakeItem("medium", 20, 30),
};
var sorted = MultiPlateNester.SortItems(items, PartSortOrder.BoundingBoxArea);
Assert.Equal("large", sorted[0].Drawing.Name);
Assert.Equal("medium", sorted[1].Drawing.Name);
Assert.Equal("small", sorted[2].Drawing.Name);
}
[Fact]
public void SortBySize_OrdersByLongestDimension()
{
var items = new List<NestItem>
{
MakeItem("short-wide", 50, 20), // longest = 50
MakeItem("tall-narrow", 10, 80), // longest = 80
MakeItem("square", 30, 30), // longest = 30
};
var sorted = MultiPlateNester.SortItems(items, PartSortOrder.Size);
Assert.Equal("tall-narrow", sorted[0].Drawing.Name);
Assert.Equal("short-wide", sorted[1].Drawing.Name);
Assert.Equal("square", sorted[2].Drawing.Name);
}
// --- Task 4: Part Classification ---
[Fact]
public void Classify_LargePart_WhenWidthExceedsHalfWorkArea()
{
var workArea = new Box(0, 0, 96, 48);
var bb = new Box(0, 0, 50, 20); // width 50 > half of 96 = 48
var result = MultiPlateNester.Classify(bb, workArea);
Assert.Equal(PartClass.Large, result);
}
[Fact]
public void Classify_LargePart_WhenLengthExceedsHalfWorkArea()
{
var workArea = new Box(0, 0, 96, 48);
var bb = new Box(0, 0, 20, 30); // length 30 > half of 48 = 24
var result = MultiPlateNester.Classify(bb, workArea);
Assert.Equal(PartClass.Large, result);
}
[Fact]
public void Classify_MediumPart_NotLargeButAreaAboveThreshold()
{
var workArea = new Box(0, 0, 96, 48);
// workArea = 4608, 1/9 = 512. bb = 40*15 = 600 > 512
// 40 < 48 (half of 96), 15 < 24 (half of 48) — not Large
var bb = new Box(0, 0, 40, 15);
var result = MultiPlateNester.Classify(bb, workArea);
Assert.Equal(PartClass.Medium, result);
}
[Fact]
public void Classify_SmallPart()
{
var workArea = new Box(0, 0, 96, 48);
// workArea = 4608, 1/9 = 512. bb = 10*10 = 100 < 512
var bb = new Box(0, 0, 10, 10);
var result = MultiPlateNester.Classify(bb, workArea);
Assert.Equal(PartClass.Small, result);
}
// --- Task 5: Scrap Zone Identification ---
[Fact]
public void IsScrapRemnant_BothDimensionsBelowThreshold_ReturnsTrue()
{
var remnant = new Box(0, 0, 10, 8);
Assert.True(MultiPlateNester.IsScrapRemnant(remnant, 12.0));
}
[Fact]
public void IsScrapRemnant_OneDimensionAboveThreshold_ReturnsFalse()
{
// 11 x 120 — narrow but long, should be preserved
var remnant = new Box(0, 0, 11, 120);
Assert.False(MultiPlateNester.IsScrapRemnant(remnant, 12.0));
}
[Fact]
public void IsScrapRemnant_BothDimensionsAboveThreshold_ReturnsFalse()
{
var remnant = new Box(0, 0, 20, 30);
Assert.False(MultiPlateNester.IsScrapRemnant(remnant, 12.0));
}
[Fact]
public void FindRemnants_ScrapOnly_ReturnsOnlyScrapRemnants()
{
// 96x48 plate with a 70x40 part placed at origin
var plate = new Plate(96, 48) { PartSpacing = 0.25 };
var drawing = MakeDrawing("big", 70, 40);
var part = new Part(drawing);
plate.Parts.Add(part);
var scrap = MultiPlateNester.FindRemnants(plate, 12.0, scrapOnly: true);
// All returned zones should have both dims < 12
foreach (var zone in scrap)
{
Assert.True(
zone.Width < 12.0 && zone.Length < 12.0,
$"Zone {zone.Width:F1}x{zone.Length:F1} is not scrap — at least one dimension >= 12"
);
}
}
// --- Task 6: Plate Creation Helper ---
[Fact]
public void CreatePlate_UsesTemplateWhenNoOptions()
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing
{
Left = 1,
Right = 1,
Top = 1,
Bottom = 1,
};
var plate = MultiPlateNester.CreatePlate(template, null, null);
Assert.Equal(96, plate.Size.Width);
Assert.Equal(48, plate.Size.Length);
Assert.Equal(0.25, plate.PartSpacing);
Assert.Equal(1, plate.Quadrant);
}
[Fact]
public void CreatePlate_PicksSmallestFittingOption()
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing
{
Left = 1,
Right = 1,
Top = 1,
Bottom = 1,
};
var options = new List<PlateOption>
{
new()
{
Width = 48,
Length = 96,
Cost = 100,
},
new()
{
Width = 60,
Length = 120,
Cost = 200,
},
new()
{
Width = 72,
Length = 144,
Cost = 300,
},
};
// Part needs 50x50 work area — 48x96 (after edge spacing: 46x94) — 46 < 50, doesn't fit.
// 60x120 (58x118) does fit.
var minBounds = new Box(0, 0, 50, 50);
var plate = MultiPlateNester.CreatePlate(template, options, minBounds);
Assert.Equal(60, plate.Size.Width);
Assert.Equal(120, plate.Size.Length);
}
[Fact]
public void EvaluateUpgrade_PrefersCheaperOption()
{
var currentOption = new PlateOption
{
Width = 48,
Length = 96,
Cost = 100,
};
var upgradeOption = new PlateOption
{
Width = 60,
Length = 120,
Cost = 160,
};
var newPlateOption = new PlateOption
{
Width = 48,
Length = 96,
Cost = 100,
};
// Upgrade cost = 160 - 100 = 60
// New plate cost with 50% utilization, 50% salvage:
// remnantFraction = 0.5, salvageCredit = 0.5 * 100 * 0.5 = 25
// netNewCost = 100 - 25 = 75
// Upgrade (60) < new plate (75), so upgrade wins
var decision = MultiPlateNester.EvaluateUpgradeVsNew(
currentOption,
upgradeOption,
newPlateOption,
0.5,
0.5
);
Assert.True(decision.ShouldUpgrade);
}
// --- Task 7: Main Orchestration ---
[Fact]
public void Nest_LargePartsGetOwnPlates()
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing();
var items = new List<NestItem> { MakeItem("big1", 80, 40, 1), MakeItem("big2", 70, 35, 1) };
var options = new MultiPlateNestOptions { Template = template };
var result = MultiPlateNester.Nest(items, options);
// Each large part should be on its own plate.
Assert.True(
result.Plates.Count >= 2,
$"Expected at least 2 plates, got {result.Plates.Count}"
);
}
[Fact]
public void Nest_SmallPartsConsolidateOntoSharedPlates()
{
// Small parts should be packed together on shared plates rather than
// each drawing getting its own plate. The consolidation pass fills
// small parts into remaining space on existing plates.
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing();
var items = new List<NestItem>
{
MakeItem("big", 80, 40, 1),
MakeItem("tinyA", 5, 5, 3),
MakeItem("tinyB", 4, 4, 3),
};
var options = new MultiPlateNestOptions { Template = template };
var result = MultiPlateNester.Nest(items, options);
// Both small drawing types should share space — not each on their own plate.
// With consolidation, they pack into remaining space alongside the big part.
Assert.True(
result.Plates.Count <= 2,
$"Expected at most 2 plates (small parts consolidated), got {result.Plates.Count}"
);
Assert.Equal(0, result.UnplacedItems.Count);
}
[Fact]
public void Nest_RespectsAllowPlateCreation()
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing();
var items = new List<NestItem> { MakeItem("big1", 80, 40, 1), MakeItem("big2", 70, 35, 1) };
var options = new MultiPlateNestOptions { Template = template, AllowPlateCreation = false };
var result = MultiPlateNester.Nest(items, options);
// No existing plates and no plate creation — nothing can be placed.
Assert.Empty(result.Plates);
Assert.Equal(2, result.UnplacedItems.Count);
}
[Fact]
public void Nest_UsesExistingPlates()
{
var template = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
template.EdgeSpacing = new Spacing();
var existingPlate = new Plate(96, 48) { PartSpacing = 0.25, Quadrant = 1 };
existingPlate.EdgeSpacing = new Spacing();
// Use a part small enough to be classified as Medium on a 96x48 plate.
// Plate WorkArea: Width=96, Length=48. Half: 48, 24.
// Part 24x22: Length=24 (not > 24), Width=22 (not > 48) — not Large.
// Area = 528 > 4608/9 = 512 — Medium.
var items = new List<NestItem> { MakeItem("medium", 24, 22, 1) };
var options = new MultiPlateNestOptions { Template = template };
var result = MultiPlateNester.Nest(
items,
options,
existingPlates: new List<Plate> { existingPlate }
);
// Part should be placed on the existing plate, not a new one.
Assert.Single(result.Plates);
Assert.False(result.Plates[0].IsNew);
}
[Fact]
public void Nest_RealNestFile_PartFirst()
{
var nestPath = @"C:\Users\aisaacs\Desktop\4526 A14 - 0.188 AISI 304.nest";
if (!File.Exists(nestPath))
{
_output.WriteLine("SKIP: nest file not found");
return;
}
var nest = new NestReader(nestPath).Read();
var template = nest.PlateDefaults.CreateNew();
_output.WriteLine(
$"Plate: {template.Size.Width}x{template.Size.Length}, "
+ $"spacing={template.PartSpacing}, edge=({template.EdgeSpacing.Left},{template.EdgeSpacing.Bottom},{template.EdgeSpacing.Right},{template.EdgeSpacing.Top})"
);
var wa = template.WorkArea();
_output.WriteLine($"Work area: {wa.Width:F1}x{wa.Length:F1}");
_output.WriteLine(
$"Classification thresholds: Large if dim > {wa.Width / 2:F1} or {wa.Length / 2:F1}, "
+ $"Medium if area > {wa.Width * wa.Length / 9:F0}"
);
_output.WriteLine("---");
var items = new List<NestItem>();
foreach (var d in nest.Drawings)
{
var qty = d.Quantity.Required > 0 ? d.Quantity.Required : d.Quantity.Remaining;
if (qty <= 0)
qty = 1;
var bb = d.Program.BoundingBox();
var classification = MultiPlateNester.Classify(bb, wa);
_output.WriteLine(
$" {d.Name, -25} {bb.Width:F1}x{bb.Length:F1} (area={bb.Width * bb.Length:F0}) qty={qty} class={classification}"
);
items.Add(
new NestItem
{
Drawing = d,
Quantity = qty,
StepAngle = d.Constraints.StepAngle,
RotationStart = d.Constraints.StartAngle,
RotationEnd = d.Constraints.EndAngle,
}
);
}
_output.WriteLine("---");
_output.WriteLine($"Total: {items.Count} drawings, {items.Sum(i => i.Quantity)} parts");
var plateOptions = new List<PlateOption>
{
new()
{
Width = 48,
Length = 96,
Cost = 0,
},
new()
{
Width = 48,
Length = 120,
Cost = 0,
},
new()
{
Width = 48,
Length = 144,
Cost = 0,
},
new()
{
Width = 60,
Length = 96,
Cost = 0,
},
new()
{
Width = 60,
Length = 120,
Cost = 0,
},
new()
{
Width = 60,
Length = 144,
Cost = 0,
},
new()
{
Width = 72,
Length = 96,
Cost = 0,
},
new()
{
Width = 72,
Length = 120,
Cost = 0,
},
new()
{
Width = 72,
Length = 144,
Cost = 0,
},
};
_output.WriteLine(
$"Plate options: {string.Join(", ", plateOptions.Select(o => $"{o.Width}x{o.Length}"))}"
);
_output.WriteLine("");
var options = new MultiPlateNestOptions
{
Template = template,
PlateOptions = plateOptions,
};
var result = MultiPlateNester.Nest(items, options);
_output.WriteLine($"=== RESULTS: {result.Plates.Count} plates ===");
for (var i = 0; i < result.Plates.Count; i++)
{
var pr = result.Plates[i];
var groups = pr
.Parts.GroupBy(p => p.BaseDrawing.Name)
.Select(g => $"{g.Key} x{g.Count()}")
.ToList();
_output.WriteLine(
$" Plate {i + 1} ({pr.Plate.Size.Width}x{pr.Plate.Size.Length}): "
+ $"{pr.Parts.Count} parts, util={pr.Plate.Utilization():P1} [{string.Join(", ", groups)}]"
);
}
if (result.UnplacedItems.Count > 0)
{
_output.WriteLine(
$" Unplaced: {string.Join(", ", result.UnplacedItems.Select(i => $"{i.Drawing.Name} x{i.Quantity}"))}"
);
}
_output.WriteLine($"\nTotal parts placed: {result.Plates.Sum(p => p.Parts.Count)}");
_output.WriteLine($"Total plates used: {result.Plates.Count}");
}
}
@@ -1,173 +0,0 @@
using OpenNest.Geometry;
using OpenNest.Engine;
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);
}
}
+2
View File
@@ -38,6 +38,8 @@ Callers must hold drawings and target state stable from snapshot through attachm
Interactive fills are outside this contract and still use `PlateFillService`. The benchmark invokes the same independent validator.
The pre-pipeline multi-plate orchestrator and plate-size optimizer have been removed from `OpenNest.Engine`; this is a source and binary break for external code that called them. Whole-job callers use `NestPipeline.Run` (or implement `INestingEngine` as a plug-in); multiple stock sizes and salvage credit are expressed as job stock and `NestJobOptions`.
## Console and MCP
Console `--autonest` uses the selected jobs engine against one physical sheet. The selected plate's old parts are replaced only after acceptance; other plates are unchanged. Default demand is still one of each drawing unless `--quantity` is supplied. A partially fulfilled, valid result may be saved; a successful placement is not a claim that all demand was met.