feat(engine): choose which edge strips count as offcut

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).
This commit is contained in:
aj committed 2026-10-31 13:29:50 -04:00
1 parent 63bcd89d35
commit 561f5be06c
13 files changed
+208 -14

No files matched your search

+6 -1
View File
@@ -47,6 +47,10 @@ namespace OpenNest.Benchmark
/// <summary>Saved source-nest setting; manifests have no saved salvage rate and use zero.</summary>
public double SalvageRate { get; init; }
/// <summary>Manifest "offcutCut": which edge strips count as a sheet's offcut;
/// null for all.</summary>
public CutOffAxis? OffcutCut { get; init; }
/// <summary>Original hand-authored placements, if the source was a .nest with any real parts.</summary>
public List<(Plate Plate, List<Part> Parts)> BaselinePlateRuns { get; init; }
public List<DrawingRequest> Requests { get; init; }
@@ -93,7 +97,8 @@ namespace OpenNest.Benchmark
"Fill",
maxPlates,
salvageRate ?? SalvageRate,
minimumSalvageDimension ?? 0
minimumSalvageDimension ?? 0,
offcutCut: OffcutCut
)
);
}
+16
View File
@@ -52,9 +52,24 @@ namespace OpenNest.Benchmark
PartSpacing = partSpacingOverride ?? manifest.Spacing,
Quadrant = manifest.Quadrant,
Requests = requests,
OffcutCut = OffcutCut(manifest.OffcutCut, manifestPath),
};
}
/// <summary>"horizontal" or "vertical" (any case); absent counts every edge strip.
/// </summary>
private static CutOffAxis? OffcutCut(string value, string manifestPath) =>
value?.ToLowerInvariant() switch
{
null => null,
"horizontal" => CutOffAxis.Horizontal,
"vertical" => CutOffAxis.Vertical,
_ => throw new InvalidOperationException(
$"Manifest '{manifestPath}' has offcutCut \"{value}\"; "
+ "use \"horizontal\" or \"vertical\"."
),
};
private static Manifest ReadManifest(string manifestPath)
{
try
@@ -206,6 +221,7 @@ namespace OpenNest.Benchmark
public double Spacing { get; set; }
public double EdgeSpacing { get; set; }
public int Quadrant { get; set; } = 1;
public string OffcutCut { get; set; }
public List<ManifestPart> Parts { get; set; }
}
+12 -3
View File
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
@@ -74,11 +75,16 @@ public sealed class PlateRemnant
/// material envelope (<paramref name="left"/> to <paramref name="top"/>) by
/// <paramref name="partSpacing"/>, with both sides at least <paramref name="minimum"/>; null
/// when none qualifies. Ties keep the first of bottom, top, left, right. This is the salvage
/// offcut whole-job costing credits.
/// offcut whole-job costing credits. A <paramref name="cut"/> keeps only the strips one cut
/// along that axis frees: <see cref="CutOffAxis.Horizontal"/> the full-width bottom and top
/// strips, <see cref="CutOffAxis.Vertical"/> the full-length left and right ones.
/// </summary>
public static Box LargestOffcut(Box workArea, double partSpacing,
double left, double bottom, double right, double top, double minimum)
double left, double bottom, double right, double top, double minimum,
CutOffAxis? cut = null)
{
if (cut is { } axis && !Enum.IsDefined(axis))
throw new ArgumentOutOfRangeException(nameof(cut));
var work = workArea;
var gap = partSpacing;
var candidates = new[]
@@ -89,8 +95,11 @@ public sealed class PlateRemnant
new Box(right + gap, work.Bottom, work.Right - right - gap, work.Width),
};
Box best = null;
foreach (var candidate in candidates)
for (var i = 0; i < candidates.Length; i++)
{
var candidate = candidates[i];
if (cut == CutOffAxis.Horizontal && i >= 2 || cut == CutOffAxis.Vertical && i < 2)
continue;
if (candidate.Length >= minimum && candidate.Width >= minimum
&& (best == null || candidate.Length * candidate.Width > best.Length * best.Width))
best = candidate;
@@ -59,6 +59,42 @@ public class ClampRemovalRecoveryTests
Assert.Equal(2, outcome.Job.Plates.Count);
}
[Fact]
public void ResidualSolvesKeepTheJobsSalvageAndOffcutSettings()
{
// The residual job is rebuilt for the reduced-clamp stock; it must score offcuts the
// way the caller's job does.
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
var job = new NestJob(new[] { part }, new[] { ClampedSheet(Clamp1(), Clamp2()) },
new NestJobOptions(salvageRate: 0.5, minimumSalvageDimension: 1,
offcutCut: CutOffAxis.Vertical));
var seen = new List<NestJobOptions>();
var engine = new Recording(seen);
var outcome = ClampRemovalRecovery.TryRecover(job, Engine.Solve(job), engine, default);
Assert.NotNull(outcome);
// Probe solves use their own unsalvaged options; every solve with the job's salvage
// settings is a residual solve and must carry its offcut cut too.
var residual = seen.Where(o => o.SalvageRate == 0.5).ToList();
Assert.NotEmpty(residual);
Assert.All(residual, o =>
{
Assert.Equal(1, o.MinimumSalvageDimension);
Assert.Equal(CutOffAxis.Vertical, o.OffcutCut);
});
}
private sealed class Recording(List<NestJobOptions> seen) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
CancellationToken token = default)
{
seen.Add(job.Options);
return Engine.Solve(job, progress, token);
}
}
[Fact]
public void RemovalFreesSpaceForOtherResidualDemandToo()
{
@@ -59,6 +59,59 @@ public class NestJobCostTests
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)]
@@ -92,6 +92,23 @@ public class DefaultNestingEngineTests
Assert.Equal("first", error.Message);
}
[Theory]
[InlineData(null, 0.0)]
[InlineData(CutOffAxis.Horizontal, 1.0)]
public void TheOffcutCutDecidesWhichEqualCostLayoutWins(CutOffAxis? cut, double expectedY)
{
// Stacked squares leave a 6 x 10 strip at the side and only 10 x 1 on top; a raised row
// leaves 10 x 5 on top. Counting every strip picks the stack; counting only full-width
// strips (a horizontal cut) picks the row.
var plain = SquaresJob();
var job = new NestJob(plain.Parts, plain.Plates, new NestJobOptions(offcutCut: cut));
var result = Choose(job,
Returns(j => Layout(j, Sheet((0, 0), (0, 5)))), Returns(j => Raised(j, 1)));
Assert.Equal(expectedY, result.Plates[0].Placements[0].Y);
}
[Fact]
public void NearTiesAreChosenInTieBreakOrderWhateverTheRunOrder()
{
@@ -144,7 +144,8 @@ public class IrregularSearchUntilCancelledTests
public void ATimeLimitAsksTheEngineToSearchUntilCancelled()
{
NestJob? seen = null;
var options = new NestJobOptions("Fill", maxPlates: 3, salvageRate: 0.25, minimumSalvageDimension: 1.5);
var options = new NestJobOptions("Fill", maxPlates: 3, salvageRate: 0.25,
minimumSalvageDimension: 1.5, offcutCut: CutOffAxis.Vertical);
var plain = MixedJob(false);
var job = new NestJob(plain.Parts, plain.Plates, options);
var engine = new DeadlineNestingEngine(
@@ -165,6 +166,7 @@ public class IrregularSearchUntilCancelledTests
Assert.Equal(3, seen.Options.MaxPlates);
Assert.Equal(0.25, seen.Options.SalvageRate);
Assert.Equal(1.5, seen.Options.MinimumSalvageDimension);
Assert.Equal(CutOffAxis.Vertical, seen.Options.OffcutCut);
Assert.Equal(job.Parts, seen.Parts);
Assert.Equal(job.Plates, seen.Plates);
Assert.False(job.Options.SearchUntilCancelled);
+2 -1
View File
@@ -163,7 +163,8 @@ internal static class ClampRemovalRecovery
var variant = Clone(group.Removal.Stock, group.Removal.Stock.Id, sheetCap, null);
var options = new NestJobOptions(job.Options.PlacementStrategy, platesLeft,
job.Options.SalvageRate, job.Options.MinimumSalvageDimension);
job.Options.SalvageRate, job.Options.MinimumSalvageDimension,
offcutCut: job.Options.OffcutCut);
var groupJob = new NestJob(parts, new[] { variant }, options);
List<NestJobPlateResult> kept = null;
+8 -5
View File
@@ -35,14 +35,14 @@ public static class NestJobCost
return 0;
var (left, bottom, right, top) = MaterialEnvelope(job, sheet);
return LargestOffcut(sheet.Stock, left, bottom, right, top,
System.Math.Max(0, job.Options.MinimumSalvageDimension));
System.Math.Max(0, job.Options.MinimumSalvageDimension), job.Options.OffcutCut);
}
/// <summary><see cref="CompactnessOffcut(NestJob, NestJobPlateResult)"/> from an existing
/// material-only placed-parts envelope.</summary>
public static double CompactnessOffcut(NestJobOptions options, NestPlateStock stock, Box partsEnvelope) =>
LargestOffcut(stock, partsEnvelope.Left, partsEnvelope.Bottom, partsEnvelope.Right, partsEnvelope.Top,
System.Math.Max(0, options.MinimumSalvageDimension));
System.Math.Max(0, options.MinimumSalvageDimension), options.OffcutCut);
/// <summary>
/// An offcut on a 1e-6 grid, for exact tie-break comparisons: differences below it are
@@ -88,14 +88,17 @@ public static class NestJobCost
var minimum = options.MinimumSalvageDimension;
if (options.SalvageRate == 0 || minimum <= 0)
return area;
return area - options.SalvageRate * LargestOffcut(stock, left, bottom, right, top, minimum);
return area - options.SalvageRate
* LargestOffcut(stock, left, bottom, right, top, minimum, options.OffcutCut);
}
// No rectangular edge strip is guaranteed inside an outlined sheet, so it earns no credit.
private static double LargestOffcut(
NestPlateStock stock, double left, double bottom, double right, double top, double minimum) =>
NestPlateStock stock, double left, double bottom, double right, double top, double minimum,
CutOffAxis? cut) =>
stock.Outline == null
&& PlateRemnant.LargestOffcut(stock.WorkArea, stock.PartSpacing, left, bottom, right, top, minimum)
&& PlateRemnant.LargestOffcut(stock.WorkArea, stock.PartSpacing,
left, bottom, right, top, minimum, cut)
is { } offcut ? offcut.Length * offcut.Width : 0;
public static bool UsesExplicitCosts(NestJob job) =>
+16 -2
View File
@@ -1,4 +1,5 @@
using System;
using OpenNest;
namespace OpenNest.Engine.Jobs;
@@ -10,9 +11,12 @@ public sealed class NestJobOptions
int? maxPlates = null,
double salvageRate = 0,
double minimumSalvageDimension = 0,
bool searchUntilCancelled = false
bool searchUntilCancelled = false,
CutOffAxis? offcutCut = null
)
{
if (offcutCut is { } axis && !Enum.IsDefined(axis))
throw new ArgumentOutOfRangeException(nameof(offcutCut));
ArgumentException.ThrowIfNullOrWhiteSpace(placementStrategy);
if (maxPlates <= 0)
throw new ArgumentOutOfRangeException(nameof(maxPlates));
@@ -25,8 +29,18 @@ public sealed class NestJobOptions
SalvageRate = salvageRate;
MinimumSalvageDimension = minimumSalvageDimension;
SearchUntilCancelled = searchUntilCancelled;
OffcutCut = offcutCut;
}
/// <summary>
/// When set, only the edge strips one cut along this axis frees count as a sheet's offcut, for
/// salvage credit and for the compactness tie-break between equal-cost plans:
/// <see cref="CutOffAxis.Horizontal"/> keeps full-width strips at the bottom or top (the drop
/// off the end of a sheet), <see cref="CutOffAxis.Vertical"/> full-length ones at the sides.
/// Null (the default) counts the largest of all four.
/// </summary>
public CutOffAxis? OffcutCut { get; }
/// <summary>
/// Asks engines that support it to keep searching after their normal search, reporting
/// each better plan (<see cref="NestJobStage.PlanImproved"/>) until the solve's token is
@@ -39,7 +53,7 @@ public sealed class NestJobOptions
/// <summary>A copy of these options with <see cref="SearchUntilCancelled"/> set.</summary>
public NestJobOptions WithSearchUntilCancelled(bool value) =>
new(PlacementStrategy, MaxPlates, SalvageRate, MinimumSalvageDimension, value);
new(PlacementStrategy, MaxPlates, SalvageRate, MinimumSalvageDimension, value, OffcutCut);
/// <summary>Fraction of eligible edge-offcut area credited by <see cref="NestJobCost.NetSheetCost"/> (0..1).</summary>
public double SalvageRate { get; }
@@ -83,6 +83,35 @@ public sealed class JobLoaderManifestTests : IDisposable
Assert.Equal(2, job.Requests.Count);
}
[Theory]
[InlineData(null, null)]
[InlineData("\"horizontal\"", CutOffAxis.Horizontal)]
[InlineData("\"Vertical\"", CutOffAxis.Vertical)]
public void OffcutCut_ReachesTheJobOptions(string json, CutOffAxis? expected)
{
var manifest = json == null ? ValidManifest
: ValidManifest.Replace("\"quadrant\": 2,", $"\"quadrant\": 2, \"offcutCut\": {json},");
var job = Assert.Single(JobLoader.Load(WriteManifest("bench.json", manifest)));
Assert.Equal(expected, job.OffcutCut);
Assert.Equal(expected, job.BuildNestJob(10).Options.OffcutCut);
}
[Theory]
[InlineData("diagonal")]
[InlineData("1")]
[InlineData("horizontal,vertical")]
[InlineData("Horizontal, Vertical")]
public void OffcutCut_RejectsAnythingButAnAxisName(string value)
{
var manifest = ValidManifest.Replace(
"\"quadrant\": 2,", $"\"quadrant\": 2, \"offcutCut\": \"{value}\",");
var path = WriteManifest("bench.json", manifest);
var ex = Assert.Throws<InvalidOperationException>(() => JobLoader.Load(path).ToList());
Assert.Contains("offcutCut", ex.Message);
}
[Fact]
public void Overrides_ReplaceManifestSheetSizesAndSpacing()
{
+9
View File
@@ -485,3 +485,12 @@ full-width or full-length edge offcut, measured as salvage measures it but whate
salvage rate (it honors a positive minimum salvage dimension; with none, any offcut counts).
Larger wins, summed over a job's sheets. It is never part of the cost, so it cannot outrank a
cheaper layout or change a reported cost.
`NestJobOptions.OffcutCut` narrows which strips count, for salvage credit and this tie-break
alike: `CutOffAxis.Horizontal` counts only the full-width strips at the bottom and top (the
drop cut off the end of a sheet), `Vertical` only the full-length ones at the sides. Unset, the
largest of the four counts. Set it when the leftover must come off one way, for example a
shop that keeps only end drops or a benchmark that scores the used length along one axis.
Only engine callers (the API and benchmark manifests) can set it so far; desktop, Console and
MCP do not offer it, and the desktop's expected-remnant display still shows the largest strip.
Fixed strategies keep their own no-salvage tie-break and ignore it.
+1 -1
View File
@@ -7,6 +7,6 @@ dotnet run --project OpenNest.Benchmark -- ./benchmark-jobs \
--sheet-sizes 48x96,60x120,72x120 --engines Irregular,Rectangles --csv results.csv
```
Supply `--sheet-sizes` for an independent candidate pool; otherwise a saved nest offers only its original sheet sizes, which can bias comparisons. A saved nest's first plate also supplies spacing, quadrant and clamps: its machine clamp rail is resolved for every candidate size, as desktop Auto Nest does, and its baseline layout is checked against the clamps it was made with. Manifests have no clamps. A manifest part rotates freely (automatic rotation: any angle) unless `"allowRotation": false` locks it at 0°, or `"rotations"` lists its only angles in degrees: `[0]`, `[0, 180]` or `[0, 90, 180, 270]`. A saved nest records its salvage rate, but salvage credit applies only with a positive `--min-salvage-dimension`. Without it, every complete one-sheet layout costs the same, as in desktop Auto Nest on a single plate size. The default `--parallel 3` runs solves concurrently and confounds per-engine timing; use `--parallel 1` for timing comparisons. `--progress` logs each solve's start, engine progress, and finish. `--time-limit <seconds>` stops each solve at that wall-clock limit and scores the best valid plan the engine had reported (see [time limits](../nesting-engines.md#time-limits)); a solve with none counts as timed out. Without a limit the harness abandons a solve after five minutes; with one it waits for the limit plus a minute, so the engine's limit always fires first. Retain input hashes, exact commands, validity, fulfillment, costs, and raw results outside source control when comparing revisions; see [fill performance verification](fill-verification.md).
Supply `--sheet-sizes` for an independent candidate pool; otherwise a saved nest offers only its original sheet sizes, which can bias comparisons. A saved nest's first plate also supplies spacing, quadrant and clamps: its machine clamp rail is resolved for every candidate size, as desktop Auto Nest does, and its baseline layout is checked against the clamps it was made with. Manifests have no clamps. A manifest part rotates freely (automatic rotation: any angle) unless `"allowRotation": false` locks it at 0°, or `"rotations"` lists its only angles in degrees: `[0]`, `[0, 180]` or `[0, 90, 180, 270]`. A manifest's `"offcutCut": "horizontal"` (or `"vertical"`) sets `NestJobOptions.OffcutCut`, so only full-width (or full-length) edge strips count as offcut; use it to match a benchmark that scores used sheet height (or length). A saved nest records its salvage rate, but salvage credit applies only with a positive `--min-salvage-dimension`. Without it, every complete one-sheet layout costs the same, as in desktop Auto Nest on a single plate size. The default `--parallel 3` runs solves concurrently and confounds per-engine timing; use `--parallel 1` for timing comparisons. `--progress` logs each solve's start, engine progress, and finish. `--time-limit <seconds>` stops each solve at that wall-clock limit and scores the best valid plan the engine had reported (see [time limits](../nesting-engines.md#time-limits)); a solve with none counts as timed out. Without a limit the harness abandons a solve after five minutes; with one it waits for the limit plus a minute, so the engine's limit always fires first. Retain input hashes, exact commands, validity, fulfillment, costs, and raw results outside source control when comparing revisions; see [fill performance verification](fill-verification.md).
Custom engines are DLLs implementing `INestingEngine` with a public parameterless constructor, loaded from `Engines/` beside the benchmark executable. See [nesting engines](../nesting-engines.md) for built-ins and engine-change rules. Run `dotnet run --project OpenNest.Benchmark -- --help` for all options.