Compare commits
17 Commits
9cba3a6cd7
...
cca70db547
| Author | SHA1 | Date | |
|---|---|---|---|
| cca70db547 | |||
| 62d9dce0b1 | |||
| 1f88453d4c | |||
| 0697bebbc2 | |||
| beadb14acc | |||
| 09f1140f54 | |||
| 7c918a2378 | |||
| feb08a5f60 | |||
| f1fd211ba5 | |||
| fd3c2462df | |||
| a4773748a1 | |||
| af57153269 | |||
| 35e89600d0 | |||
| 89a4e6b981 | |||
| ebad3577dd | |||
| a8dc275da4 | |||
| d84becdaee |
@@ -0,0 +1,597 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum PartClass
|
||||
{
|
||||
Large,
|
||||
Medium,
|
||||
Small,
|
||||
}
|
||||
|
||||
public class MultiPlateNester
|
||||
{
|
||||
private readonly Plate _template;
|
||||
private readonly List<PlateOption> _plateOptions;
|
||||
private readonly double _salvageRate;
|
||||
private readonly double _minRemnantSize;
|
||||
private readonly List<PlateResult> _platePool;
|
||||
private readonly IProgress<NestProgress> _progress;
|
||||
private readonly CancellationToken _token;
|
||||
|
||||
private MultiPlateNester(
|
||||
Plate template, List<PlateOption> plateOptions,
|
||||
double salvageRate, double minRemnantSize,
|
||||
List<Plate> existingPlates,
|
||||
IProgress<NestProgress> progress, CancellationToken token)
|
||||
{
|
||||
_template = template;
|
||||
_plateOptions = plateOptions;
|
||||
_salvageRate = salvageRate;
|
||||
_minRemnantSize = minRemnantSize;
|
||||
_platePool = InitializePlatePool(existingPlates);
|
||||
_progress = progress;
|
||||
_token = token;
|
||||
}
|
||||
|
||||
// --- Static Utility Methods ---
|
||||
|
||||
public static List<NestItem> SortItems(List<NestItem> items, PartSortOrder sortOrder)
|
||||
{
|
||||
switch (sortOrder)
|
||||
{
|
||||
case PartSortOrder.BoundingBoxArea:
|
||||
return items
|
||||
.OrderByDescending(i =>
|
||||
{
|
||||
var bb = i.Drawing.Program.BoundingBox();
|
||||
return bb.Width * bb.Length;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
case PartSortOrder.Size:
|
||||
return items
|
||||
.OrderByDescending(i =>
|
||||
{
|
||||
var bb = i.Drawing.Program.BoundingBox();
|
||||
return System.Math.Max(bb.Width, bb.Length);
|
||||
})
|
||||
.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)
|
||||
{
|
||||
var workW = option.Width - template.EdgeSpacing.Left - template.EdgeSpacing.Right;
|
||||
var workL = option.Length - template.EdgeSpacing.Top - template.EdgeSpacing.Bottom;
|
||||
|
||||
var fitsNormal = workW >= minBounds.Width - Tolerance.Epsilon
|
||||
&& workL >= minBounds.Length - Tolerance.Epsilon;
|
||||
var fitsRotated = workW >= minBounds.Length - Tolerance.Epsilon
|
||||
&& workL >= minBounds.Width - Tolerance.Epsilon;
|
||||
|
||||
if (fitsNormal || fitsRotated)
|
||||
{
|
||||
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,
|
||||
Plate template,
|
||||
List<PlateOption> plateOptions,
|
||||
double salvageRate,
|
||||
PartSortOrder sortOrder,
|
||||
double minRemnantSize,
|
||||
bool allowPlateCreation,
|
||||
List<Plate> existingPlates,
|
||||
IProgress<NestProgress> progress,
|
||||
CancellationToken token)
|
||||
{
|
||||
var nester = new MultiPlateNester(template, plateOptions, salvageRate,
|
||||
minRemnantSize, existingPlates, progress, token);
|
||||
return nester.Run(items, sortOrder, allowPlateCreation);
|
||||
}
|
||||
|
||||
// --- Private Helpers ---
|
||||
|
||||
private static double ScoreZone(Box zone, Box partBounds)
|
||||
{
|
||||
var fitsNormal = zone.Length >= partBounds.Length && zone.Width >= partBounds.Width;
|
||||
var fitsRotated = zone.Length >= partBounds.Width && zone.Width >= partBounds.Length;
|
||||
|
||||
if (!fitsNormal && !fitsRotated)
|
||||
return -1;
|
||||
|
||||
return (partBounds.Length * partBounds.Width) / zone.Area();
|
||||
}
|
||||
|
||||
private int FillAndPlace(PlateResult pr, Box zone, NestItem item)
|
||||
{
|
||||
var engine = NestEngineRegistry.Create(pr.Plate);
|
||||
var clonedItem = CloneItem(item);
|
||||
var parts = engine.Fill(clonedItem, zone, _progress, _token);
|
||||
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
pr.Plate.Parts.AddRange(parts);
|
||||
pr.Parts.AddRange(parts);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
|
||||
}
|
||||
|
||||
return parts.Count;
|
||||
}
|
||||
|
||||
private PlateResult CreateNewPlateResult(Plate plate)
|
||||
{
|
||||
var pr = new PlateResult { Plate = plate, IsNew = true };
|
||||
|
||||
if (_plateOptions != null)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// --- 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 && _plateOptions != null && _plateOptions.Count > 0)
|
||||
TryUpgradeOrNewPlate(item, bb);
|
||||
}
|
||||
}
|
||||
|
||||
var leftovers = sorted.Where(i => i.Quantity > 0).ToList();
|
||||
|
||||
if (leftovers.Count > 0 && allowPlateCreation && !_token.IsCancellationRequested)
|
||||
{
|
||||
PackIntoExistingRemnants(leftovers);
|
||||
CreateSharedPlates(leftovers);
|
||||
}
|
||||
|
||||
if (_plateOptions != null && _plateOptions.Count > 0 && !_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 = NestEngineRegistry.Create(pr.Plate);
|
||||
var cloned = remaining.Select(CloneItem).ToList();
|
||||
var parts = engine.PackArea(remnants[0], cloned, _progress, _token);
|
||||
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
pr.Plate.Parts.AddRange(parts);
|
||||
pr.Parts.AddRange(parts);
|
||||
anyPlaced = true;
|
||||
|
||||
foreach (var item in remaining)
|
||||
{
|
||||
var placed = parts.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - 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 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 = NestEngineRegistry.Create(plate);
|
||||
var clonedItem = CloneItem(item);
|
||||
var parts = engine.Fill(clonedItem, remnants[0], _progress, _token);
|
||||
|
||||
if (parts.Count > 0)
|
||||
{
|
||||
plate.Parts.AddRange(parts);
|
||||
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
|
||||
placedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!placedAny)
|
||||
break;
|
||||
|
||||
var pr = CreateNewPlateResult(plate);
|
||||
pr.Parts.AddRange(plate.Parts);
|
||||
_platePool.Add(pr);
|
||||
leftovers.RemoveAll(i => i.Quantity <= 0);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryPlaceOnExistingPlates(NestItem item, Box partBounds)
|
||||
{
|
||||
var anyPlaced = false;
|
||||
|
||||
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;
|
||||
|
||||
var workArea = pr.Plate.WorkArea();
|
||||
var classification = Classify(partBounds, workArea);
|
||||
|
||||
var remnants = classification == PartClass.Small
|
||||
? FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: true)
|
||||
: FindRemnants(pr.Plate, _minRemnantSize, scrapOnly: false);
|
||||
|
||||
foreach (var zone in remnants)
|
||||
{
|
||||
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;
|
||||
|
||||
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 (partBounds.Length > workArea.Length && partBounds.Length > workArea.Width)
|
||||
break;
|
||||
if (partBounds.Width > workArea.Width && partBounds.Width > workArea.Length)
|
||||
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 (_plateOptions == null || _plateOptions.Count == 0)
|
||||
return false;
|
||||
|
||||
var sortedOptions = _plateOptions.OrderBy(o => o.Cost).ToList();
|
||||
|
||||
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];
|
||||
|
||||
// Only consider options that are at least as large in both dimensions.
|
||||
if (upgradeOption.Width < currentOption.Width - Tolerance.Epsilon
|
||||
|| upgradeOption.Length < currentOption.Length - Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var smallestNew = sortedOptions.FirstOrDefault(o =>
|
||||
{
|
||||
var ww = o.Width - _template.EdgeSpacing.Left - _template.EdgeSpacing.Right;
|
||||
var wl = o.Length - _template.EdgeSpacing.Top - _template.EdgeSpacing.Bottom;
|
||||
return (ww >= partBounds.Width && wl >= partBounds.Length)
|
||||
|| (ww >= partBounds.Length && wl >= partBounds.Width);
|
||||
});
|
||||
|
||||
if (smallestNew == null)
|
||||
continue;
|
||||
|
||||
var utilEst = pr.Plate.Utilization();
|
||||
var decision = EvaluateUpgradeVsNew(currentOption, upgradeOption, smallestNew,
|
||||
_salvageRate, utilEst);
|
||||
|
||||
if (decision.ShouldUpgrade)
|
||||
{
|
||||
var oldSize = pr.Plate.Size;
|
||||
var oldChosenSize = pr.ChosenSize;
|
||||
|
||||
pr.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length);
|
||||
pr.ChosenSize = upgradeOption;
|
||||
|
||||
var remainingArea = RemnantFinder.FromPlate(pr.Plate).FindRemnants();
|
||||
|
||||
if (remainingArea.Count > 0 && FillAndPlace(pr, remainingArea[0], item) > 0)
|
||||
return true;
|
||||
|
||||
// Revert if nothing was placed.
|
||||
pr.Plate.Size = oldSize;
|
||||
pr.ChosenSize = oldChosenSize;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void TryConsolidateTailPlates()
|
||||
{
|
||||
var activePlates = _platePool.Where(p => p.Parts.Count > 0 && p.IsNew).ToList();
|
||||
if (activePlates.Count < 2)
|
||||
return;
|
||||
|
||||
var sortedOptions = _plateOptions.OrderBy(o => o.Cost).ToList();
|
||||
|
||||
// Try to absorb the smallest-utilization new plate into another plate via upgrade.
|
||||
var donor = activePlates.OrderBy(p => p.Plate.Utilization()).First();
|
||||
var donorParts = donor.Parts.ToList();
|
||||
|
||||
foreach (var target in activePlates)
|
||||
{
|
||||
if (target == donor || target.ChosenSize == null)
|
||||
continue;
|
||||
|
||||
var currentOption = target.ChosenSize;
|
||||
|
||||
// Try each larger option that doesn't shrink any dimension.
|
||||
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)))
|
||||
{
|
||||
var oldSize = target.Plate.Size;
|
||||
var oldChosenSize = target.ChosenSize;
|
||||
|
||||
target.Plate.Size = new Size(upgradeOption.Width, upgradeOption.Length);
|
||||
target.ChosenSize = upgradeOption;
|
||||
|
||||
var remnants = RemnantFinder.FromPlate(target.Plate).FindRemnants();
|
||||
if (remnants.Count == 0)
|
||||
{
|
||||
target.Plate.Size = oldSize;
|
||||
target.ChosenSize = oldChosenSize;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to pack all donor parts into the remnant space.
|
||||
var engine = NestEngineRegistry.Create(target.Plate);
|
||||
var tempItems = donorParts
|
||||
.GroupBy(p => p.BaseDrawing.Name)
|
||||
.Select(g => new NestItem
|
||||
{
|
||||
Drawing = g.First().BaseDrawing,
|
||||
Quantity = g.Count(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var placed = engine.PackArea(remnants[0], tempItems, _progress, _token);
|
||||
|
||||
if (placed.Count >= donorParts.Count)
|
||||
{
|
||||
// All donor parts fit — absorb them.
|
||||
target.Plate.Parts.AddRange(placed);
|
||||
target.Parts.AddRange(placed);
|
||||
|
||||
foreach (var p in donorParts)
|
||||
donor.Plate.Parts.Remove(p);
|
||||
donor.Parts.Clear();
|
||||
_platePool.Remove(donor);
|
||||
return;
|
||||
}
|
||||
|
||||
// Didn't fit all parts — revert.
|
||||
target.Plate.Size = oldSize;
|
||||
target.ChosenSize = oldChosenSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OpenNest
|
||||
{
|
||||
public enum PartSortOrder
|
||||
{
|
||||
BoundingBoxArea,
|
||||
Size,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
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 result = MultiPlateNester.Nest(
|
||||
items, template,
|
||||
plateOptions: null,
|
||||
salvageRate: 0.5,
|
||||
sortOrder: PartSortOrder.BoundingBoxArea,
|
||||
minRemnantSize: 12.0,
|
||||
allowPlateCreation: true,
|
||||
existingPlates: null,
|
||||
progress: null,
|
||||
token: CancellationToken.None);
|
||||
|
||||
// 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 result = MultiPlateNester.Nest(
|
||||
items, template,
|
||||
plateOptions: null,
|
||||
salvageRate: 0.5,
|
||||
sortOrder: PartSortOrder.BoundingBoxArea,
|
||||
minRemnantSize: 12.0,
|
||||
allowPlateCreation: true,
|
||||
existingPlates: null,
|
||||
progress: null,
|
||||
token: CancellationToken.None);
|
||||
|
||||
// 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 result = MultiPlateNester.Nest(
|
||||
items, template,
|
||||
plateOptions: null,
|
||||
salvageRate: 0.5,
|
||||
sortOrder: PartSortOrder.BoundingBoxArea,
|
||||
minRemnantSize: 12.0,
|
||||
allowPlateCreation: false,
|
||||
existingPlates: null,
|
||||
progress: null,
|
||||
token: CancellationToken.None);
|
||||
|
||||
// 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 result = MultiPlateNester.Nest(
|
||||
items, template,
|
||||
plateOptions: null,
|
||||
salvageRate: 0.5,
|
||||
sortOrder: PartSortOrder.BoundingBoxArea,
|
||||
minRemnantSize: 12.0,
|
||||
allowPlateCreation: true,
|
||||
existingPlates: new List<Plate> { existingPlate },
|
||||
progress: null,
|
||||
token: CancellationToken.None);
|
||||
|
||||
// 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 result = MultiPlateNester.Nest(
|
||||
items, template,
|
||||
plateOptions: plateOptions,
|
||||
salvageRate: 0.5,
|
||||
sortOrder: PartSortOrder.BoundingBoxArea,
|
||||
minRemnantSize: 12.0,
|
||||
allowPlateCreation: true,
|
||||
existingPlates: null,
|
||||
progress: null,
|
||||
token: CancellationToken.None);
|
||||
|
||||
_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}");
|
||||
}
|
||||
}
|
||||
Generated
+149
-61
@@ -17,13 +17,20 @@ namespace OpenNest.Forms
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.engineLabel = new System.Windows.Forms.Label();
|
||||
this.engineComboBox = new System.Windows.Forms.ComboBox();
|
||||
this.partsGroup = new System.Windows.Forms.GroupBox();
|
||||
this.tabControl = new System.Windows.Forms.TabControl();
|
||||
this.partsTab = new System.Windows.Forms.TabPage();
|
||||
this.platesTab = new System.Windows.Forms.TabPage();
|
||||
this.partsGrid = new System.Windows.Forms.DataGridView();
|
||||
this.summaryLabel = new System.Windows.Forms.Label();
|
||||
this.optionsGroup = new System.Windows.Forms.GroupBox();
|
||||
this.engineLabel = new System.Windows.Forms.Label();
|
||||
this.engineComboBox = new System.Windows.Forms.ComboBox();
|
||||
this.createNewPlatesAsNeededBox = new System.Windows.Forms.CheckBox();
|
||||
this.partFirstGroup = new System.Windows.Forms.GroupBox();
|
||||
this.partFirstCheckBox = new System.Windows.Forms.CheckBox();
|
||||
this.sortOrderLabel = new System.Windows.Forms.Label();
|
||||
this.sortOrderComboBox = new System.Windows.Forms.ComboBox();
|
||||
this.minRemnantLabel = new System.Windows.Forms.Label();
|
||||
this.minRemnantBox = new System.Windows.Forms.TextBox();
|
||||
this.plateOptimizerGroup = new System.Windows.Forms.GroupBox();
|
||||
this.optimizePlateSizeBox = new System.Windows.Forms.CheckBox();
|
||||
this.plateGrid = new System.Windows.Forms.DataGridView();
|
||||
@@ -33,42 +40,53 @@ namespace OpenNest.Forms
|
||||
this.buttonPanel = new System.Windows.Forms.Panel();
|
||||
this.acceptButton = new System.Windows.Forms.Button();
|
||||
this.cancelButton = new System.Windows.Forms.Button();
|
||||
this.tabControl.SuspendLayout();
|
||||
this.partsTab.SuspendLayout();
|
||||
this.platesTab.SuspendLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.partsGrid)).BeginInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.plateGrid)).BeginInit();
|
||||
this.partsGroup.SuspendLayout();
|
||||
this.optionsGroup.SuspendLayout();
|
||||
this.partFirstGroup.SuspendLayout();
|
||||
this.plateOptimizerGroup.SuspendLayout();
|
||||
this.buttonPanel.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// engineLabel
|
||||
// tabControl
|
||||
//
|
||||
this.engineLabel.AutoSize = true;
|
||||
this.engineLabel.Location = new System.Drawing.Point(12, 15);
|
||||
this.engineLabel.Name = "engineLabel";
|
||||
this.engineLabel.Size = new System.Drawing.Size(82, 16);
|
||||
this.engineLabel.TabIndex = 0;
|
||||
this.engineLabel.Text = "Nest Engine:";
|
||||
this.tabControl.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.tabControl.Controls.Add(this.partsTab);
|
||||
this.tabControl.Controls.Add(this.platesTab);
|
||||
this.tabControl.Location = new System.Drawing.Point(12, 12);
|
||||
this.tabControl.Name = "tabControl";
|
||||
this.tabControl.SelectedIndex = 0;
|
||||
this.tabControl.Size = new System.Drawing.Size(556, 490);
|
||||
this.tabControl.TabIndex = 0;
|
||||
//
|
||||
// engineComboBox
|
||||
// partsTab
|
||||
//
|
||||
this.engineComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.engineComboBox.Location = new System.Drawing.Point(100, 12);
|
||||
this.engineComboBox.Name = "engineComboBox";
|
||||
this.engineComboBox.Size = new System.Drawing.Size(200, 24);
|
||||
this.engineComboBox.TabIndex = 1;
|
||||
this.partsTab.Controls.Add(this.partsGrid);
|
||||
this.partsTab.Controls.Add(this.summaryLabel);
|
||||
this.partsTab.Location = new System.Drawing.Point(4, 25);
|
||||
this.partsTab.Name = "partsTab";
|
||||
this.partsTab.Padding = new System.Windows.Forms.Padding(6);
|
||||
this.partsTab.Size = new System.Drawing.Size(548, 461);
|
||||
this.partsTab.TabIndex = 0;
|
||||
this.partsTab.Text = "Parts";
|
||||
this.partsTab.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// partsGroup
|
||||
// platesTab
|
||||
//
|
||||
this.partsGroup.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.partsGroup.Controls.Add(this.partsGrid);
|
||||
this.partsGroup.Controls.Add(this.summaryLabel);
|
||||
this.partsGroup.Location = new System.Drawing.Point(12, 42);
|
||||
this.partsGroup.Name = "partsGroup";
|
||||
this.partsGroup.Size = new System.Drawing.Size(556, 210);
|
||||
this.partsGroup.TabIndex = 2;
|
||||
this.partsGroup.TabStop = false;
|
||||
this.partsGroup.Text = "Parts";
|
||||
this.platesTab.Controls.Add(this.engineLabel);
|
||||
this.platesTab.Controls.Add(this.engineComboBox);
|
||||
this.platesTab.Controls.Add(this.createNewPlatesAsNeededBox);
|
||||
this.platesTab.Controls.Add(this.partFirstGroup);
|
||||
this.platesTab.Controls.Add(this.plateOptimizerGroup);
|
||||
this.platesTab.Location = new System.Drawing.Point(4, 25);
|
||||
this.platesTab.Name = "platesTab";
|
||||
this.platesTab.Padding = new System.Windows.Forms.Padding(6);
|
||||
this.platesTab.Size = new System.Drawing.Size(548, 461);
|
||||
this.platesTab.TabIndex = 1;
|
||||
this.platesTab.Text = "Plates";
|
||||
this.platesTab.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// partsGrid
|
||||
//
|
||||
@@ -78,43 +96,108 @@ namespace OpenNest.Forms
|
||||
this.partsGrid.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.partsGrid.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
|
||||
this.partsGrid.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
|
||||
this.partsGrid.Location = new System.Drawing.Point(10, 22);
|
||||
this.partsGrid.Location = new System.Drawing.Point(10, 10);
|
||||
this.partsGrid.Name = "partsGrid";
|
||||
this.partsGrid.RowHeadersVisible = false;
|
||||
this.partsGrid.AutoGenerateColumns = false;
|
||||
this.partsGrid.Size = new System.Drawing.Size(536, 160);
|
||||
this.partsGrid.Size = new System.Drawing.Size(528, 420);
|
||||
this.partsGrid.TabIndex = 0;
|
||||
//
|
||||
// summaryLabel
|
||||
//
|
||||
this.summaryLabel.AutoSize = true;
|
||||
this.summaryLabel.ForeColor = System.Drawing.SystemColors.GrayText;
|
||||
this.summaryLabel.Location = new System.Drawing.Point(10, 188);
|
||||
this.summaryLabel.Location = new System.Drawing.Point(10, 436);
|
||||
this.summaryLabel.Name = "summaryLabel";
|
||||
this.summaryLabel.Size = new System.Drawing.Size(0, 16);
|
||||
this.summaryLabel.TabIndex = 1;
|
||||
//
|
||||
// optionsGroup
|
||||
// engineLabel
|
||||
//
|
||||
this.optionsGroup.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.optionsGroup.Controls.Add(this.createNewPlatesAsNeededBox);
|
||||
this.optionsGroup.Location = new System.Drawing.Point(12, 258);
|
||||
this.optionsGroup.Name = "optionsGroup";
|
||||
this.optionsGroup.Size = new System.Drawing.Size(556, 48);
|
||||
this.optionsGroup.TabIndex = 3;
|
||||
this.optionsGroup.TabStop = false;
|
||||
this.optionsGroup.Text = "Options";
|
||||
this.engineLabel.AutoSize = true;
|
||||
this.engineLabel.Location = new System.Drawing.Point(10, 15);
|
||||
this.engineLabel.Name = "engineLabel";
|
||||
this.engineLabel.Size = new System.Drawing.Size(82, 16);
|
||||
this.engineLabel.TabIndex = 0;
|
||||
this.engineLabel.Text = "Nest Engine:";
|
||||
//
|
||||
// engineComboBox
|
||||
//
|
||||
this.engineComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.engineComboBox.Location = new System.Drawing.Point(98, 12);
|
||||
this.engineComboBox.Name = "engineComboBox";
|
||||
this.engineComboBox.Size = new System.Drawing.Size(200, 24);
|
||||
this.engineComboBox.TabIndex = 1;
|
||||
//
|
||||
// createNewPlatesAsNeededBox
|
||||
//
|
||||
this.createNewPlatesAsNeededBox.AutoSize = true;
|
||||
this.createNewPlatesAsNeededBox.Location = new System.Drawing.Point(10, 22);
|
||||
this.createNewPlatesAsNeededBox.Location = new System.Drawing.Point(10, 44);
|
||||
this.createNewPlatesAsNeededBox.Name = "createNewPlatesAsNeededBox";
|
||||
this.createNewPlatesAsNeededBox.Size = new System.Drawing.Size(202, 20);
|
||||
this.createNewPlatesAsNeededBox.TabIndex = 0;
|
||||
this.createNewPlatesAsNeededBox.TabIndex = 2;
|
||||
this.createNewPlatesAsNeededBox.Text = "Create new plates as needed";
|
||||
this.createNewPlatesAsNeededBox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// partFirstGroup
|
||||
//
|
||||
this.partFirstGroup.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.partFirstGroup.Controls.Add(this.partFirstCheckBox);
|
||||
this.partFirstGroup.Controls.Add(this.sortOrderLabel);
|
||||
this.partFirstGroup.Controls.Add(this.sortOrderComboBox);
|
||||
this.partFirstGroup.Controls.Add(this.minRemnantLabel);
|
||||
this.partFirstGroup.Controls.Add(this.minRemnantBox);
|
||||
this.partFirstGroup.Location = new System.Drawing.Point(10, 72);
|
||||
this.partFirstGroup.Name = "partFirstGroup";
|
||||
this.partFirstGroup.Size = new System.Drawing.Size(528, 80);
|
||||
this.partFirstGroup.TabIndex = 3;
|
||||
this.partFirstGroup.TabStop = false;
|
||||
this.partFirstGroup.Text = " Part-First Mode";
|
||||
//
|
||||
// partFirstCheckBox
|
||||
//
|
||||
this.partFirstCheckBox.AutoSize = true;
|
||||
this.partFirstCheckBox.Location = new System.Drawing.Point(10, 0);
|
||||
this.partFirstCheckBox.Name = "partFirstCheckBox";
|
||||
this.partFirstCheckBox.Size = new System.Drawing.Size(15, 14);
|
||||
this.partFirstCheckBox.TabIndex = 0;
|
||||
this.partFirstCheckBox.UseVisualStyleBackColor = true;
|
||||
this.partFirstCheckBox.CheckedChanged += new System.EventHandler(this.partFirstCheckBox_CheckedChanged);
|
||||
//
|
||||
// sortOrderLabel
|
||||
//
|
||||
this.sortOrderLabel.AutoSize = true;
|
||||
this.sortOrderLabel.Location = new System.Drawing.Point(10, 26);
|
||||
this.sortOrderLabel.Name = "sortOrderLabel";
|
||||
this.sortOrderLabel.Size = new System.Drawing.Size(75, 16);
|
||||
this.sortOrderLabel.TabIndex = 1;
|
||||
this.sortOrderLabel.Text = "Sort Order:";
|
||||
//
|
||||
// sortOrderComboBox
|
||||
//
|
||||
this.sortOrderComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.sortOrderComboBox.Location = new System.Drawing.Point(100, 23);
|
||||
this.sortOrderComboBox.Name = "sortOrderComboBox";
|
||||
this.sortOrderComboBox.Size = new System.Drawing.Size(180, 24);
|
||||
this.sortOrderComboBox.TabIndex = 2;
|
||||
//
|
||||
// minRemnantLabel
|
||||
//
|
||||
this.minRemnantLabel.AutoSize = true;
|
||||
this.minRemnantLabel.Location = new System.Drawing.Point(10, 54);
|
||||
this.minRemnantLabel.Name = "minRemnantLabel";
|
||||
this.minRemnantLabel.Size = new System.Drawing.Size(117, 16);
|
||||
this.minRemnantLabel.TabIndex = 3;
|
||||
this.minRemnantLabel.Text = "Min Remnant Size:";
|
||||
//
|
||||
// minRemnantBox
|
||||
//
|
||||
this.minRemnantBox.Location = new System.Drawing.Point(133, 51);
|
||||
this.minRemnantBox.Name = "minRemnantBox";
|
||||
this.minRemnantBox.Size = new System.Drawing.Size(60, 22);
|
||||
this.minRemnantBox.TabIndex = 4;
|
||||
this.minRemnantBox.Text = "12";
|
||||
//
|
||||
// plateOptimizerGroup
|
||||
//
|
||||
this.plateOptimizerGroup.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right)));
|
||||
@@ -123,9 +206,9 @@ namespace OpenNest.Forms
|
||||
this.plateOptimizerGroup.Controls.Add(this.salvageRateLabel);
|
||||
this.plateOptimizerGroup.Controls.Add(this.salvageRateBox);
|
||||
this.plateOptimizerGroup.Controls.Add(this.salvageRatePercentLabel);
|
||||
this.plateOptimizerGroup.Location = new System.Drawing.Point(12, 312);
|
||||
this.plateOptimizerGroup.Location = new System.Drawing.Point(10, 158);
|
||||
this.plateOptimizerGroup.Name = "plateOptimizerGroup";
|
||||
this.plateOptimizerGroup.Size = new System.Drawing.Size(556, 188);
|
||||
this.plateOptimizerGroup.Size = new System.Drawing.Size(528, 188);
|
||||
this.plateOptimizerGroup.TabIndex = 4;
|
||||
this.plateOptimizerGroup.TabStop = false;
|
||||
this.plateOptimizerGroup.Text = " Plate Optimizer";
|
||||
@@ -150,7 +233,7 @@ namespace OpenNest.Forms
|
||||
this.plateGrid.Name = "plateGrid";
|
||||
this.plateGrid.RowHeadersVisible = false;
|
||||
this.plateGrid.AutoGenerateColumns = false;
|
||||
this.plateGrid.Size = new System.Drawing.Size(536, 130);
|
||||
this.plateGrid.Size = new System.Drawing.Size(508, 130);
|
||||
this.plateGrid.TabIndex = 1;
|
||||
//
|
||||
// salvageRateLabel
|
||||
@@ -187,7 +270,7 @@ namespace OpenNest.Forms
|
||||
this.buttonPanel.Location = new System.Drawing.Point(0, 506);
|
||||
this.buttonPanel.Name = "buttonPanel";
|
||||
this.buttonPanel.Size = new System.Drawing.Size(580, 50);
|
||||
this.buttonPanel.TabIndex = 5;
|
||||
this.buttonPanel.TabIndex = 1;
|
||||
//
|
||||
// acceptButton
|
||||
//
|
||||
@@ -217,11 +300,7 @@ namespace OpenNest.Forms
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
|
||||
this.CancelButton = this.cancelButton;
|
||||
this.ClientSize = new System.Drawing.Size(580, 556);
|
||||
this.Controls.Add(this.engineLabel);
|
||||
this.Controls.Add(this.engineComboBox);
|
||||
this.Controls.Add(this.partsGroup);
|
||||
this.Controls.Add(this.optionsGroup);
|
||||
this.Controls.Add(this.plateOptimizerGroup);
|
||||
this.Controls.Add(this.tabControl);
|
||||
this.Controls.Add(this.buttonPanel);
|
||||
this.Font = new System.Drawing.Font("Microsoft Sans Serif", 9.75F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
|
||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
|
||||
@@ -232,28 +311,37 @@ namespace OpenNest.Forms
|
||||
this.ShowInTaskbar = false;
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
this.Text = "AutoNest";
|
||||
this.tabControl.ResumeLayout(false);
|
||||
this.partsTab.ResumeLayout(false);
|
||||
this.partsTab.PerformLayout();
|
||||
this.platesTab.ResumeLayout(false);
|
||||
this.platesTab.PerformLayout();
|
||||
((System.ComponentModel.ISupportInitialize)(this.partsGrid)).EndInit();
|
||||
((System.ComponentModel.ISupportInitialize)(this.plateGrid)).EndInit();
|
||||
this.partsGroup.ResumeLayout(false);
|
||||
this.partsGroup.PerformLayout();
|
||||
this.optionsGroup.ResumeLayout(false);
|
||||
this.optionsGroup.PerformLayout();
|
||||
this.partFirstGroup.ResumeLayout(false);
|
||||
this.partFirstGroup.PerformLayout();
|
||||
this.plateOptimizerGroup.ResumeLayout(false);
|
||||
this.plateOptimizerGroup.PerformLayout();
|
||||
this.buttonPanel.ResumeLayout(false);
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.Label engineLabel;
|
||||
private System.Windows.Forms.ComboBox engineComboBox;
|
||||
private System.Windows.Forms.GroupBox partsGroup;
|
||||
private System.Windows.Forms.TabControl tabControl;
|
||||
private System.Windows.Forms.TabPage partsTab;
|
||||
private System.Windows.Forms.TabPage platesTab;
|
||||
private System.Windows.Forms.DataGridView partsGrid;
|
||||
private System.Windows.Forms.Label summaryLabel;
|
||||
private System.Windows.Forms.GroupBox optionsGroup;
|
||||
private System.Windows.Forms.Label engineLabel;
|
||||
private System.Windows.Forms.ComboBox engineComboBox;
|
||||
private System.Windows.Forms.CheckBox createNewPlatesAsNeededBox;
|
||||
private System.Windows.Forms.GroupBox partFirstGroup;
|
||||
private System.Windows.Forms.CheckBox partFirstCheckBox;
|
||||
private System.Windows.Forms.Label sortOrderLabel;
|
||||
private System.Windows.Forms.ComboBox sortOrderComboBox;
|
||||
private System.Windows.Forms.Label minRemnantLabel;
|
||||
private System.Windows.Forms.TextBox minRemnantBox;
|
||||
private System.Windows.Forms.GroupBox plateOptimizerGroup;
|
||||
private System.Windows.Forms.CheckBox optimizePlateSizeBox;
|
||||
private System.Windows.Forms.DataGridView plateGrid;
|
||||
|
||||
@@ -22,6 +22,11 @@ namespace OpenNest.Forms
|
||||
LoadDefaultPlateOptions();
|
||||
SetPlateOptimizerVisible(false);
|
||||
|
||||
sortOrderComboBox.Items.Add("Bounding Box Area");
|
||||
sortOrderComboBox.Items.Add("Size");
|
||||
sortOrderComboBox.SelectedIndex = 0;
|
||||
SetPartFirstVisible(false);
|
||||
|
||||
partsGrid.DataError += PartsGrid_DataError;
|
||||
}
|
||||
|
||||
@@ -54,6 +59,32 @@ namespace OpenNest.Forms
|
||||
set { salvageRateBox.Text = (value * 100).ToString("F0"); }
|
||||
}
|
||||
|
||||
public bool PartFirstMode
|
||||
{
|
||||
get { return partFirstCheckBox.Checked; }
|
||||
set { partFirstCheckBox.Checked = value; }
|
||||
}
|
||||
|
||||
public PartSortOrder SortOrder
|
||||
{
|
||||
get
|
||||
{
|
||||
if (sortOrderComboBox.SelectedItem is string s && s == "Size")
|
||||
return PartSortOrder.Size;
|
||||
return PartSortOrder.BoundingBoxArea;
|
||||
}
|
||||
}
|
||||
|
||||
public double MinRemnantSize
|
||||
{
|
||||
get
|
||||
{
|
||||
if (double.TryParse(minRemnantBox.Text, out var val) && val > 0)
|
||||
return val;
|
||||
return 12.0;
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadEngines()
|
||||
{
|
||||
foreach (var engine in NestEngineRegistry.AvailableEngines)
|
||||
@@ -242,6 +273,19 @@ namespace OpenNest.Forms
|
||||
salvageRatePercentLabel.Visible = visible;
|
||||
}
|
||||
|
||||
private void partFirstCheckBox_CheckedChanged(object sender, EventArgs e)
|
||||
{
|
||||
SetPartFirstVisible(partFirstCheckBox.Checked);
|
||||
}
|
||||
|
||||
private void SetPartFirstVisible(bool visible)
|
||||
{
|
||||
sortOrderLabel.Visible = visible;
|
||||
sortOrderComboBox.Visible = visible;
|
||||
minRemnantLabel.Visible = visible;
|
||||
minRemnantBox.Visible = visible;
|
||||
}
|
||||
|
||||
private void UpdateSummary()
|
||||
{
|
||||
var gridItems = partsGrid.DataSource as List<DataGridViewItem>;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using OpenNest.Bending;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.IO.Bending;
|
||||
using OpenNest.IO.Bom;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -470,12 +472,19 @@ namespace OpenNest.Forms
|
||||
{
|
||||
var result = Dxf.Import(part.DxfPath);
|
||||
|
||||
var bends = new List<Bend>();
|
||||
if (result.Document != null)
|
||||
bends = BendDetectorRegistry.AutoDetect(result.Document);
|
||||
Bend.UpdateEtchEntities(result.Entities, bends);
|
||||
|
||||
var drawingName = Path.GetFileNameWithoutExtension(part.DxfPath);
|
||||
var drawing = new Drawing(drawingName);
|
||||
drawing.Color = Drawing.GetNextColor();
|
||||
drawing.Source.Path = part.DxfPath;
|
||||
drawing.Quantity.Required = part.Qty ?? 1;
|
||||
drawing.Material = new Material(material);
|
||||
if (bends.Count > 0)
|
||||
drawing.Bends.AddRange(bends);
|
||||
|
||||
var normalized = ShapeProfile.NormalizeEntities(result.Entities);
|
||||
var pgm = ConvertGeometry.ToProgram(normalized);
|
||||
|
||||
@@ -932,6 +932,10 @@ namespace OpenNest.Forms
|
||||
var optimizePlateSize = form.OptimizePlateSize;
|
||||
var plateOptions = optimizePlateSize ? form.GetPlateOptions() : null;
|
||||
var salvageRate = form.SalvageRate;
|
||||
var partFirstMode = form.PartFirstMode;
|
||||
var sortOrder = form.SortOrder;
|
||||
var minRemnantSize = form.MinRemnantSize;
|
||||
var allowPlateCreation = form.AllowPlateCreation;
|
||||
|
||||
if (optimizePlateSize)
|
||||
{
|
||||
@@ -960,7 +964,7 @@ namespace OpenNest.Forms
|
||||
try
|
||||
{
|
||||
await RunAutoNestAsync(items, progressForm, progress, nestingCts.Token,
|
||||
plateOptions, salvageRate);
|
||||
plateOptions, salvageRate, partFirstMode, sortOrder, minRemnantSize, allowPlateCreation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -984,8 +988,43 @@ namespace OpenNest.Forms
|
||||
IProgress<NestProgress> progress,
|
||||
CancellationToken token,
|
||||
List<PlateOption> plateOptions = null,
|
||||
double salvageRate = 0.5)
|
||||
double salvageRate = 0.5,
|
||||
bool partFirstMode = false,
|
||||
PartSortOrder sortOrder = PartSortOrder.BoundingBoxArea,
|
||||
double minRemnantSize = 12.0,
|
||||
bool allowPlateCreation = true)
|
||||
{
|
||||
if (partFirstMode)
|
||||
{
|
||||
var existingPlates = new List<Plate>();
|
||||
for (var i = 0; i < activeForm.Nest.Plates.Count; i++)
|
||||
{
|
||||
var p = activeForm.Nest.Plates[i];
|
||||
if (p.Parts.Count > 0)
|
||||
existingPlates.Add(p);
|
||||
}
|
||||
|
||||
var template = activeForm.PlateView.Plate;
|
||||
|
||||
var result = await Task.Run(() =>
|
||||
MultiPlateNester.Nest(items, template, plateOptions, salvageRate,
|
||||
sortOrder, minRemnantSize, allowPlateCreation, existingPlates, progress, token));
|
||||
|
||||
foreach (var pr in result.Plates)
|
||||
{
|
||||
if (pr.IsNew)
|
||||
{
|
||||
var plate = GetOrCreatePlate(progressForm);
|
||||
plate.Size = pr.Plate.Size;
|
||||
plate.Parts.AddRange(pr.Parts);
|
||||
}
|
||||
}
|
||||
|
||||
activeForm.Nest.UpdateDrawingQuantities();
|
||||
progressForm.ShowCompleted();
|
||||
return;
|
||||
}
|
||||
|
||||
const int maxPlates = 100;
|
||||
|
||||
for (var plateIndex = 0; plateIndex < maxPlates; plateIndex++)
|
||||
|
||||
Reference in New Issue
Block a user