mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-06 12:25:43 -05:00
Equal-cost plans break ties by the largest edge offcut, from any of the four edges. A caller that wants the leftover off one way, such as a shop that keeps only end drops, or a benchmark scoring the used sheet height, had no way to say so. On the converted Nestbolt jobs the extended Irregular search then found plans whose larger offcut is a strip down the side, which Nestbolt scores as worse: b-blaz1-strip 3.601 -> 3.867, b-shirts-strip 2.674 -> 2.995. NestJobOptions.OffcutCut (a CutOffAxis, default null) counts only the strips one cut along that axis frees, for salvage credit and the compactness tie-break alike: Horizontal the full-width bottom and top strips, Vertical the full-length sides. PlateRemnant.LargestOffcut takes the axis; NestJobCost, the time limit and clamp-removal recovery pass it on. Unset, nothing changes. Benchmark manifests accept "offcutCut": "horizontal" or "vertical". With horizontal, one CPU, 20 s limit, Default: b-blaz1-strip 3.934 -> 3.333 b-shirts-strip 2.995 -> 2.610 m-box-deep 1.918 -> 1.276 (Nestbolt 1.251) Manifests accept only the words horizontal or vertical (any case); a list such as "horizontal,vertical" is rejected. PlateRemnant rejects an undefined axis. Fixed strategies keep their own no-salvage tie-break and ignore the option; desktop, Console and MCP do not offer it yet. Mutation reds: 14/14 (offcut-mutations.py).
203 lines
10 KiB
C#
203 lines
10 KiB
C#
using OpenNest.CNC;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Engine.Tests.Jobs;
|
|
|
|
public class NestJobCostTests
|
|
{
|
|
public static IEnumerable<object[]> EdgeCases()
|
|
{
|
|
foreach (var rate in new[] { 0.0, 0.5, 1.0 })
|
|
foreach (var edge in new[] { "bottom", "top", "left", "right" })
|
|
foreach (var quadrant in new[] { 1, 2, 3, 4 })
|
|
yield return new object[] { rate, edge, quadrant };
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(EdgeCases))]
|
|
public void EveryEdgeMatchesFrozenScoringExactly(double rate, string edge, int quadrant)
|
|
{
|
|
var stock = new NestPlateStock("sheet", new Size(20, 20), partSpacing: 0.25,
|
|
edgeSpacing: new Spacing(1, 1, 1, 1), quadrant: quadrant);
|
|
var work = stock.WorkArea;
|
|
var width = edge is "left" or "right" ? 4 : 18;
|
|
var height = edge is "bottom" or "top" ? 4 : 18;
|
|
var x = work.Left + (edge == "left" ? 14 : 0);
|
|
var y = work.Bottom + (edge == "bottom" ? 14 : 0);
|
|
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(
|
|
TestDrawingFactory.Rectangle(width, height)), 2);
|
|
var job = new NestJob(new[] { part }, new[] { stock },
|
|
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 2));
|
|
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, x, y, 0) });
|
|
var expected = 400 - rate * (18 * 13.75);
|
|
|
|
Assert.Equal(expected, LegacyNestJobCost.EstimateNetArea(job, sheet));
|
|
Assert.Equal(expected, NestJobCost.NetSheetArea(job, sheet));
|
|
Assert.Equal(expected, NestJobCost.NetSheetArea(job.Options, stock, new Box(x, y, width, height)));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 0, 247.5)]
|
|
[InlineData(0.5, 0, 247.5)]
|
|
[InlineData(0, 13.75, 247.5)]
|
|
[InlineData(0, 14, 0)]
|
|
public void CompactnessOffcutIgnoresSalvageRateButKeepsTheMinimum(double rate, double minimum, double expected)
|
|
{
|
|
var stock = new NestPlateStock("sheet", new Size(20, 20), partSpacing: 0.25,
|
|
edgeSpacing: new Spacing(1, 1, 1, 1));
|
|
var work = stock.WorkArea;
|
|
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(18, 4)), 1);
|
|
var job = new NestJob(new[] { part }, new[] { stock },
|
|
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum));
|
|
var sheet = new NestJobPlateResult(0, stock,
|
|
new[] { new NestJobPlacement("part", 0, work.Left, work.Bottom, 0) });
|
|
|
|
// The strip above the part: full work width by 18 - 4 - 0.25 spacing.
|
|
Assert.Equal(expected, NestJobCost.CompactnessOffcut(job, sheet));
|
|
Assert.Equal(expected, NestJobCost.CompactnessOffcut(job.Options, stock, new Box(work.Left, work.Bottom, 18, 4)));
|
|
Assert.Equal(0, NestJobCost.CompactnessOffcut(job, new NestJobPlateResult(0, stock, Array.Empty<NestJobPlacement>())));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null, 211.5)]
|
|
[InlineData(CutOffAxis.Horizontal, 139.5)]
|
|
[InlineData(CutOffAxis.Vertical, 211.5)]
|
|
public void AnOffcutCutCountsOnlyTheStripsThatCutFrees(CutOffAxis? cut, double expected)
|
|
{
|
|
// A 6 x 10 part in the corner of an 18 x 18 work area leaves a full-width strip on top,
|
|
// 18 x 7.75, and a larger full-length strip on the right, 11.75 x 18.
|
|
var stock = new NestPlateStock("sheet", new Size(20, 20), partSpacing: 0.25,
|
|
edgeSpacing: new Spacing(1, 1, 1, 1));
|
|
var work = stock.WorkArea;
|
|
var part = new NestJobPart("part",
|
|
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 10)), 1);
|
|
var job = new NestJob(new[] { part }, new[] { stock },
|
|
new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 2, offcutCut: cut));
|
|
var sheet = new NestJobPlateResult(0, stock,
|
|
new[] { new NestJobPlacement("part", 0, work.Left, work.Bottom, 0) });
|
|
var envelope = new Box(work.Left, work.Bottom, 6, 10);
|
|
|
|
Assert.Equal(expected, NestJobCost.CompactnessOffcut(job, sheet));
|
|
Assert.Equal(expected, NestJobCost.CompactnessOffcut(job.Options, stock, envelope));
|
|
Assert.Equal(400 - expected, NestJobCost.NetSheetArea(job, sheet));
|
|
Assert.Equal(400 - expected, NestJobCost.NetSheetArea(job.Options, stock, envelope));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null, 211.5)]
|
|
[InlineData(CutOffAxis.Horizontal, 211.5)]
|
|
[InlineData(CutOffAxis.Vertical, 139.5)]
|
|
public void AVerticalCutCountsOnlyTheSideStrips(CutOffAxis? cut, double expected)
|
|
{
|
|
// The mirror case: a 10 x 6 part leaves 18 x 11.75 on top but only 7.75 x 18 at the side.
|
|
var stock = new NestPlateStock("sheet", new Size(20, 20), partSpacing: 0.25,
|
|
edgeSpacing: new Spacing(1, 1, 1, 1));
|
|
var work = stock.WorkArea;
|
|
var options = new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 2,
|
|
offcutCut: cut);
|
|
var envelope = new Box(work.Left, work.Bottom, 10, 6);
|
|
|
|
Assert.Equal(expected, NestJobCost.CompactnessOffcut(options, stock, envelope));
|
|
Assert.Equal(400 - expected, NestJobCost.NetSheetArea(options, stock, envelope));
|
|
}
|
|
|
|
[Fact]
|
|
public void AnUndefinedAxisIsRejectedByTheRemnantRule() =>
|
|
Assert.Throws<ArgumentOutOfRangeException>(() => PlateRemnant.LargestOffcut(
|
|
new Box(0, 0, 10, 10), 0, 1, 1, 2, 2, 0, (CutOffAxis)7));
|
|
|
|
[Fact]
|
|
public void AnUndefinedOffcutCutIsRejected() =>
|
|
Assert.Throws<ArgumentOutOfRangeException>(
|
|
() => new NestJobOptions(offcutCut: (CutOffAxis)7));
|
|
|
|
[Theory]
|
|
[InlineData(0, 5, 100)]
|
|
[InlineData(0.5, 0, 100)]
|
|
[InlineData(0.5, 7, 100)]
|
|
[InlineData(0.5, 6, 70)]
|
|
[InlineData(0.5, 5, 70)]
|
|
public void NetSheetAreaRetainsLegacyThresholds(double rate, double minimum, double expected)
|
|
{
|
|
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 4)), 1);
|
|
var stock = new NestPlateStock("sheet", new Size(10, 10));
|
|
var job = new NestJob(new[] { part }, new[] { stock },
|
|
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum));
|
|
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) });
|
|
|
|
Assert.Equal(expected, NestJobCost.NetSheetArea(job, sheet));
|
|
Assert.Equal(LegacyNestJobCost.EstimateNetArea(job, sheet), NestJobCost.NetSheetArea(job, sheet));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, false)]
|
|
[InlineData(0.5, false)]
|
|
[InlineData(0, true)]
|
|
[InlineData(0.5, true)]
|
|
public void RotatedPartsAndMultipleSheetsUseMaterialCost(double rate, bool marked)
|
|
{
|
|
var program = TestDrawingFactory.Rectangle(4, 3);
|
|
if (marked)
|
|
{
|
|
program.MoveTo(2, 2);
|
|
program.Codes.Add(new LinearMove(9, 2) { Layer = LayerType.Scribe });
|
|
}
|
|
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 5);
|
|
var stock = new NestPlateStock("sheet", new Size(30, 40));
|
|
var other = new NestPlateStock("large", new Size(50, 50));
|
|
var job = new NestJob(new[] { part }, new[] { stock, other },
|
|
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: 2));
|
|
var builder = new NestJobResultBuilder(job);
|
|
builder.AddSheet(stock, new[] { ("part", 10.123, 8.456, 0.37), ("part", 25.789, 17.321, 1.12) });
|
|
builder.AddSheet(other, new[] { ("part", 12.345, 19.876, 2.13) });
|
|
var result = builder.Build(NestJobStopReason.NoPlacementFound);
|
|
var cleanPart = new NestJobPart("part", PartGeometrySnapshot.FromProgram(
|
|
TestDrawingFactory.Rectangle(4, 3)), 5);
|
|
var cleanJob = new NestJob(new[] { cleanPart }, job.Plates, job.Options);
|
|
foreach (var sheet in result.Plates)
|
|
{
|
|
var area = LegacyNestJobCost.EstimateNetArea(cleanJob, sheet);
|
|
Assert.Equal(area, NestJobCost.NetSheetArea(job, sheet));
|
|
var priced = new NestPlateStock(sheet.Stock.Id, sheet.Stock.Size, sheet.Stock.Quantity,
|
|
sheet.Stock.PartSpacing, sheet.Stock.EdgeSpacing, sheet.Stock.Quadrant, 37);
|
|
var pricedSheet = new NestJobPlateResult(sheet.PlateIndex, priced, sheet.Placements);
|
|
Assert.Equal(37 * (area / sheet.Stock.Area), NestJobCost.NetSheetCost(job, pricedSheet));
|
|
}
|
|
Assert.Equal(2500, NestJobCost.UnplacedPartPenalty(job));
|
|
Assert.Equal(result.Plates.Sum(sheet => LegacyNestJobCost.EstimateNetArea(cleanJob, sheet)) + 5000,
|
|
NestJobCost.Evaluate(job, result));
|
|
}
|
|
|
|
[Fact]
|
|
public void EtchBeyondMaterialDoesNotShrinkSalvageOffcut()
|
|
{
|
|
var program = TestDrawingFactory.Rectangle(4, 3);
|
|
program.MoveTo(2, 2);
|
|
program.Codes.Add(new LinearMove(15, 2) { Layer = LayerType.Scribe });
|
|
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 1);
|
|
var stock = new NestPlateStock("sheet", new Size(10, 20));
|
|
var job = new NestJob(new[] { part }, new[] { stock },
|
|
new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1));
|
|
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("part", 0, 0, 0, 0) });
|
|
|
|
Assert.Equal(130, LegacyNestJobCost.EstimateNetArea(job, sheet));
|
|
Assert.Equal(120, NestJobCost.NetSheetArea(job, sheet));
|
|
Assert.Equal(120, NestJobCost.NetSheetArea(job.Options, stock, JobPartGeometry.Read(part.Geometry).Bounds));
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptySheetsAndEmptyStockRetainBenchmarkSemantics()
|
|
{
|
|
var stock = new NestPlateStock("sheet", new Size(10, 10));
|
|
var job = new NestJob(Array.Empty<NestJobPart>(), new[] { stock },
|
|
new NestJobOptions(salvageRate: 1, minimumSalvageDimension: 1));
|
|
var sheet = new NestJobPlateResult(0, stock, Array.Empty<NestJobPlacement>());
|
|
Assert.Equal(100, NestJobCost.NetSheetArea(job, sheet));
|
|
Assert.Equal(LegacyNestJobCost.EstimateNetArea(job, sheet), NestJobCost.NetSheetArea(job, sheet));
|
|
var empty = new NestJob(Array.Empty<NestJobPart>(), Array.Empty<NestPlateStock>());
|
|
Assert.Equal(0, NestJobCost.UnplacedPartPenalty(empty));
|
|
Assert.Equal(0, NestJobCost.Evaluate(empty, new NestJobResultBuilder(empty).Build(NestJobStopReason.Completed)));
|
|
}
|
|
}
|