diff --git a/OpenNest.Benchmark/BenchmarkJob.cs b/OpenNest.Benchmark/BenchmarkJob.cs
index 2a70855..063f827 100644
--- a/OpenNest.Benchmark/BenchmarkJob.cs
+++ b/OpenNest.Benchmark/BenchmarkJob.cs
@@ -1,9 +1,9 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
-using OpenNest.Geometry;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
+using OpenNest.Geometry;
namespace OpenNest.Benchmark
{
@@ -36,6 +36,12 @@ namespace OpenNest.Benchmark
public Spacing EdgeSpacing { get; init; }
public double PartSpacing { get; init; }
public int Quadrant { get; init; }
+
+ /// Saved source-nest setting; manifests have no saved salvage rate and use zero.
+ public double SalvageRate { get; init; }
+
+ /// Original hand-authored placements, if the source was a .nest with any real parts.
+ public List<(Plate Plate, List Parts)> BaselinePlateRuns { get; init; }
public List Requests { get; init; }
public string Name => Path.GetFileNameWithoutExtension(SourceFile);
@@ -58,8 +64,8 @@ namespace OpenNest.Benchmark
///
public NestJob BuildNestJob(
int maxPlates,
- double salvageRate = 0,
- double minimumSalvageDimension = 0
+ double? salvageRate = null,
+ double? minimumSalvageDimension = null
)
{
var parts = Requests.Select(r =>
@@ -76,7 +82,12 @@ namespace OpenNest.Benchmark
return new NestJob(
parts,
stock,
- new NestJobOptions("Default", maxPlates, salvageRate, minimumSalvageDimension)
+ new NestJobOptions(
+ "Default",
+ maxPlates,
+ salvageRate ?? SalvageRate,
+ minimumSalvageDimension ?? 0
+ )
);
}
}
diff --git a/OpenNest.Benchmark/BenchmarkRunner.cs b/OpenNest.Benchmark/BenchmarkRunner.cs
index 14e58c4..65d8488 100644
--- a/OpenNest.Benchmark/BenchmarkRunner.cs
+++ b/OpenNest.Benchmark/BenchmarkRunner.cs
@@ -30,8 +30,8 @@ namespace OpenNest.Benchmark
public static List Run(
List jobs,
IReadOnlyList engines,
- double salvageRate = 0,
- double minimumSalvageDimension = 0,
+ double? salvageRate = null,
+ double? minimumSalvageDimension = null,
string outputDirectory = null,
int maxParallelism = 1
)
@@ -44,6 +44,16 @@ namespace OpenNest.Benchmark
MaxDegreeOfParallelism = System.Math.Max(1, maxParallelism),
};
+ var baselineResults = new JobResult[jobs.Count];
+ Parallel.ForEach(
+ Partitioner.Create(
+ Enumerable.Range(0, jobs.Count),
+ EnumerablePartitionerOptions.NoBuffering
+ ),
+ options,
+ i => baselineResults[i] = RunBaseline(jobs[i], salvageRate, minimumSalvageDimension)
+ );
+
// NoBuffering hands out one pair at a time: solves run for seconds to minutes,
// so chunked partitioning would leave workers idle behind a slow engine.
Parallel.ForEach(
@@ -63,14 +73,156 @@ namespace OpenNest.Benchmark
);
// Indexed writes keep the report in job-then-engine order whatever finishes first.
- return results.ToList();
+ var ordered = new List(
+ results.Length + baselineResults.Count(result => result != null)
+ );
+ for (var jobIndex = 0; jobIndex < jobs.Count; jobIndex++)
+ {
+ if (baselineResults[jobIndex] != null)
+ ordered.Add(baselineResults[jobIndex]);
+ var firstResult = jobIndex * engines.Count;
+ for (var engineIndex = 0; engineIndex < engines.Count; engineIndex++)
+ ordered.Add(results[firstResult + engineIndex]);
+ }
+ return ordered;
+ }
+
+ private static JobResult RunBaseline(
+ BenchmarkJob job,
+ double? salvageRate,
+ double? minimumSalvageDimension
+ )
+ {
+ if (job.BaselinePlateRuns == null)
+ return null;
+ var requested = job.TotalRequestedQuantity;
+ try
+ {
+ var requirements = job.Requests.ToDictionary<
+ DrawingRequest,
+ Drawing,
+ (string Name, int Quantity)
+ >(
+ request => request.Drawing,
+ request => (request.Drawing.Name, request.Quantity),
+ ReferenceEqualityComparer.Instance
+ );
+ var partIds = job.Requests.ToDictionary(
+ request => request.Drawing,
+ request => request.Drawing.Id.ToString(),
+ ReferenceEqualityComparer.Instance
+ );
+ var validation = NestValidator.Validate(job.BaselinePlateRuns, requirements);
+ var benchmarkJob = job.BuildNestJob(
+ MaxPlates,
+ salvageRate,
+ minimumSalvageDimension
+ );
+ var instanceIndices = new Dictionary(StringComparer.Ordinal);
+ var plateResults = job
+ .BaselinePlateRuns.Select(
+ (run, index) =>
+ {
+ var stock = new NestPlateStock(
+ $"baseline-{index}",
+ run.Plate.Size,
+ 1,
+ run.Plate.PartSpacing,
+ run.Plate.EdgeSpacing,
+ run.Plate.Quadrant
+ );
+ var placements = run
+ .Parts.Select(part =>
+ {
+ var partId = partIds[part.BaseDrawing];
+ instanceIndices.TryGetValue(partId, out var instanceIndex);
+ instanceIndices[partId] = instanceIndex + 1;
+ return new NestJobPlacement(
+ partId,
+ instanceIndex,
+ part.Location.X,
+ part.Location.Y,
+ part.Rotation
+ );
+ })
+ .ToList();
+ return new NestJobPlateResult(index, stock, placements);
+ }
+ )
+ .ToList();
+ var baselineJob = new NestJob(
+ benchmarkJob.Parts,
+ plateResults.Select(result => result.Stock),
+ benchmarkJob.Options
+ );
+ var baselineJobResult = new NestJobResult(
+ NestJobStatus.Complete,
+ NestJobStopReason.Completed,
+ plateResults,
+ Array.Empty(),
+ Array.Empty()
+ );
+ NestValidator.ValidateAgainstJob(
+ baselineJob,
+ baselineJobResult,
+ job.Requests.ToDictionary(
+ request => request.Drawing.Id.ToString(),
+ request => request.Drawing.Name
+ ),
+ validation
+ );
+
+ var plateRuns = job.BaselinePlateRuns;
+ var placedArea = validation.Valid
+ ? plateRuns.Sum(run => run.Parts.Sum(part => part.BaseDrawing.Area))
+ : 0;
+ var plateArea = plateRuns.Sum(run => run.Plate.Area());
+ var netSheetArea = validation.Valid
+ ? plateResults.Sum(result =>
+ StockLadderNestingEngine.EstimateNetArea(baselineJob, result)
+ )
+ : 0;
+ var sizeBreakdown = plateRuns
+ .GroupBy(run => run.Plate.Size.ToString(1))
+ .OrderByDescending(group => group.Count())
+ .ToDictionary(group => group.Key, group => group.Count());
+
+ return new JobResult
+ {
+ EngineName = "Baseline",
+ JobName = job.Name,
+ Valid = validation.Valid,
+ Violations = validation.Violations,
+ PartsPlaced = plateRuns.Sum(run => run.Parts.Count),
+ PartsRequested = requested,
+ PlacedArea = placedArea,
+ PlateArea = plateArea,
+ NetSheetArea = netSheetArea,
+ UnplacedPartPenalty = job.UnplacedPartPenalty,
+ PlatesUsed = plateRuns.Count,
+ SizeBreakdown = sizeBreakdown,
+ ElapsedMs = 0,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JobResult
+ {
+ EngineName = "Baseline",
+ JobName = job.Name,
+ Valid = false,
+ PartsRequested = requested,
+ UnplacedPartPenalty = job.UnplacedPartPenalty,
+ Error = $"{ex.GetType().Name}: {ex.Message}",
+ };
+ }
}
private static JobResult RunOne(
BenchmarkJob job,
NestingEngineInfo engineInfo,
- double salvageRate,
- double minimumSalvageDimension,
+ double? salvageRate,
+ double? minimumSalvageDimension,
string outputDirectory
)
{
@@ -113,7 +265,9 @@ namespace OpenNest.Benchmark
var plateArea = plateRuns.Sum(pr => pr.Plate.Area());
// Salvage credit is recomputed from the job's own geometry, never taken from the engine.
var netSheetArea = validation.Valid
- ? jobResult.Plates.Sum(p => StockLadderNestingEngine.EstimateNetArea(nestJob, p))
+ ? jobResult.Plates.Sum(p =>
+ StockLadderNestingEngine.EstimateNetArea(nestJob, p)
+ )
: 0;
var sizeBreakdown = plateRuns
@@ -138,7 +292,7 @@ namespace OpenNest.Benchmark
{
materialized.Nest.Name = job.Name;
}
- materialized.Nest.SalvageRate = salvageRate;
+ materialized.Nest.SalvageRate = nestJob.Options.SalvageRate;
foreach (var request in job.Requests)
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request
.Drawing
@@ -165,8 +319,8 @@ namespace OpenNest.Benchmark
Placed = totalPlaced,
SheetArea = plateArea,
PlacedArea = placedArea,
- SalvageRate = salvageRate,
- MinimumSalvageDimension = minimumSalvageDimension,
+ SalvageRate = nestJob.Options.SalvageRate,
+ MinimumSalvageDimension = nestJob.Options.MinimumSalvageDimension,
EstimatedNetArea = netSheetArea,
Fulfillment = jobResult.Fulfillment,
StockUsage = jobResult.StockUsage,
diff --git a/OpenNest.Benchmark/JobLoader.cs b/OpenNest.Benchmark/JobLoader.cs
index 94ba9dc..20e160a 100644
--- a/OpenNest.Benchmark/JobLoader.cs
+++ b/OpenNest.Benchmark/JobLoader.cs
@@ -73,6 +73,8 @@ namespace OpenNest.Benchmark
EdgeSpacing = template.EdgeSpacing,
PartSpacing = partSpacingOverride ?? template.PartSpacing,
Quadrant = template.Quadrant,
+ SalvageRate = nest.SalvageRate,
+ BaselinePlateRuns = BuildBaselinePlateRuns(nest, partSpacingOverride),
Requests = requests,
}
);
@@ -156,6 +158,35 @@ namespace OpenNest.Benchmark
return requests;
}
+ private static List<(Plate Plate, List Parts)> BuildBaselinePlateRuns(
+ Nest nest,
+ double? partSpacingOverride
+ )
+ {
+ var runs = new List<(Plate Plate, List Parts)>();
+ foreach (var plate in nest.Plates ?? Enumerable.Empty())
+ {
+ var parts = plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).ToList();
+ if (parts.Count == 0)
+ continue;
+ var validationPlate = new Plate(new Size(plate.Size.Width, plate.Size.Length))
+ {
+ Quantity = 1,
+ Quadrant = plate.Quadrant,
+ PartSpacing = partSpacingOverride ?? plate.PartSpacing,
+ EdgeSpacing = new Spacing(
+ plate.EdgeSpacing.Left,
+ plate.EdgeSpacing.Bottom,
+ plate.EdgeSpacing.Right,
+ plate.EdgeSpacing.Top
+ ),
+ };
+ for (var copy = 0; copy < plate.Quantity; copy++)
+ runs.Add((validationPlate, parts));
+ }
+ return runs.Count > 0 ? runs : null;
+ }
+
private static (Spacing EdgeSpacing, double PartSpacing, int Quadrant) ResolvePlateTemplate(
Nest nest
)
diff --git a/OpenNest.Benchmark/Program.cs b/OpenNest.Benchmark/Program.cs
index 8e700fa..9a1aae3 100644
--- a/OpenNest.Benchmark/Program.cs
+++ b/OpenNest.Benchmark/Program.cs
@@ -4,8 +4,8 @@ using System.IO;
using System.Linq;
using OpenNest;
using OpenNest.Benchmark;
-using OpenNest.Geometry;
using OpenNest.Engine.Jobs;
+using OpenNest.Geometry;
return BenchmarkConsole.Run(args);
@@ -96,6 +96,17 @@ static class BenchmarkConsole
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
+ var effectiveSalvageRates = jobs.Select(job => options.SalvageRate ?? job.SalvageRate);
+ if (
+ effectiveSalvageRates.Any(rate => rate > 0)
+ && (options.MinimumSalvageDimension ?? 0) <= 0
+ )
+ {
+ Console.Error.WriteLine(
+ "Warning: salvage credit is disabled because --min-salvage-dimension was not set to a positive value."
+ );
+ }
+
var solves = jobs.Count * engines.Count;
if (options.Parallel > 1 && solves > 1)
@@ -294,13 +305,13 @@ static class BenchmarkConsole
" --csv Write a flat CSV of all results"
);
Console.Error.WriteLine(
- " --salvage-rate <0..1> Fraction of the usable offcut credited back in the"
+ " --salvage-rate <0..1> Fraction of eligible offcut area credited (default: saved .nest rate;"
);
Console.Error.WriteLine(
- " score (default 0; needs --min-salvage-dimension)"
+ " manifests 0; needs positive --min-salvage-dimension)"
);
Console.Error.WriteLine(
- " --min-salvage-dimension Both offcut dimensions must qualify; 0 disables credit"
+ " --min-salvage-dimension Both offcut dimensions must qualify; positive value enables credit (default 0)"
);
Console.Error.WriteLine(
" --output Save valid layouts as .nest plus detailed JSON reports"
@@ -322,8 +333,8 @@ static class BenchmarkConsole
public List EngineNames = new();
public string CsvPath;
public string OutputDirectory;
- public double SalvageRate;
- public double MinimumSalvageDimension;
+ public double? SalvageRate;
+ public double? MinimumSalvageDimension;
public int Parallel = 3;
}
}
diff --git a/OpenNest.Engine.Tests/Jobs/NestJobGeometryTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobGeometryTests.cs
index ebff149..9f16169 100644
--- a/OpenNest.Engine.Tests/Jobs/NestJobGeometryTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/NestJobGeometryTests.cs
@@ -1,6 +1,6 @@
-using OpenNest.Geometry;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
+using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
@@ -101,6 +101,94 @@ public class NestJobGeometryTests
Assert.Equal(NestJobStatus.Complete, boundedResult.Status);
}
+ [Fact]
+ public void LegacyAllow180EquivalentPermitsFlippedCandidate()
+ {
+ var drawing = new Drawing("grain-aligned", TestDrawingFactory.Rectangle(4, 2));
+ drawing.Constraints.StepAngle = System.Math.PI / 2;
+ drawing.Constraints.StartAngle = 0;
+ drawing.Constraints.EndAngle = 0;
+ drawing.Constraints.Allow180Equivalent = true;
+ var job = new NestJob(
+ new[] { DrawingJobMapper.FromDrawing("part", drawing, 1) },
+ new[] { new NestPlateStock("stock", new Size(10, 10), 1) }
+ );
+
+ var result = Solve(job, new NestJobPlacement("part", 0, 4, 2, System.Math.PI));
+
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ }
+
+ [Fact]
+ public void LegacyItemAllow180EquivalentPermitsFlippedCandidate()
+ {
+ var drawing = new Drawing("grain-aligned", TestDrawingFactory.Rectangle(4, 2));
+ drawing.Constraints.Allow180Equivalent = true;
+ var item = new NestItem
+ {
+ Drawing = drawing,
+ Quantity = 1,
+ StepAngle = System.Math.PI / 2,
+ RotationStart = 0,
+ RotationEnd = 0,
+ };
+ var job = new NestJob(
+ new[] { DrawingJobMapper.FromItem("part", item) },
+ new[] { new NestPlateStock("stock", new Size(10, 10), 1) }
+ );
+
+ var result = Solve(job, new NestJobPlacement("part", 0, 4, 2, System.Math.PI));
+
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ }
+
+ [Fact]
+ public void BoundedRotationPolicyAcceptsAnEquivalentFullTurn()
+ {
+ var part = new NestJobPart(
+ "part",
+ PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 2)),
+ 1,
+ rotation: RotationPolicy.BoundedSweep(
+ -System.Math.PI / 2,
+ System.Math.PI / 2,
+ System.Math.PI / 2
+ )
+ );
+ var job = new NestJob(
+ new[] { part },
+ new[] { new NestPlateStock("stock", new Size(10, 10), 1) }
+ );
+
+ var result = Solve(job, new NestJobPlacement("part", 0, 0, 4, 3 * System.Math.PI / 2));
+
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ }
+
+ [Fact]
+ public void BoundedRotationSpanningFullTurnAcceptsGridAngleAndEquivalent()
+ {
+ var part = new NestJobPart(
+ "part",
+ PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 2)),
+ 1,
+ rotation: RotationPolicy.BoundedSweep(0, 7, 1)
+ );
+ var job = new NestJob(
+ new[] { part },
+ new[] { new NestPlateStock("stock", new Size(10, 10), 1) }
+ );
+
+ Assert.Equal(
+ NestJobStatus.Complete,
+ Solve(job, new NestJobPlacement("part", 0, 0, 0, 0)).Status
+ );
+ Assert.Equal(
+ NestJobStatus.Complete,
+ Solve(job, new NestJobPlacement("part", 0, 0, 0, System.Math.PI * 2)).Status
+ );
+ }
+
[Fact]
public void EdgeTouchingIsAllowedAtZeroSpacingAndRejectedAtPositiveSpacing()
{
diff --git a/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs b/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs
index a292a23..9cdf366 100644
--- a/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs
@@ -1,8 +1,8 @@
using OpenNest.CNC;
-using OpenNest.Geometry;
-using Xunit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
+using OpenNest.Geometry;
+using Xunit;
namespace OpenNest.Engine.Tests.Jobs;
@@ -169,6 +169,22 @@ public class PlateNesterParityTests
Assert.Equal(2, ByPart(result)["arc"].Placed);
}
+ [Fact]
+ public void RestrictedRotation_Enumerates180EquivalentFlip()
+ {
+ var anglesMethod = typeof(OrderedPlateNester).GetMethod(
+ "Angles",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static
+ );
+ var policy = RotationPolicy.BoundedSweep(0, 0, System.Math.PI / 2, true);
+
+ var angles = (
+ (IEnumerable)anglesMethod!.Invoke(null, new object[] { policy })!
+ ).ToList();
+
+ Assert.Equal(new[] { 0.0, System.Math.PI }, angles);
+ }
+
[Fact]
public void FixedRotation_Respected()
{
diff --git a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
index ca91470..0cb8260 100644
--- a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
+++ b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
@@ -20,7 +20,8 @@ public static class DrawingJobMapper
: RotationPolicy.FromLegacy(
constraints.StepAngle,
constraints.StartAngle,
- constraints.EndAngle
+ constraints.EndAngle,
+ constraints.Allow180Equivalent
)
);
}
@@ -34,7 +35,12 @@ public static class DrawingJobMapper
PartGeometrySnapshot.FromProgram(item.Drawing.Program),
item.Quantity,
item.Priority,
- RotationPolicy.FromLegacy(item.StepAngle, item.RotationStart, item.RotationEnd)
+ RotationPolicy.FromLegacy(
+ item.StepAngle,
+ item.RotationStart,
+ item.RotationEnd,
+ item.Drawing.Constraints?.Allow180Equivalent ?? false
+ )
);
}
@@ -89,6 +95,7 @@ public static class DrawingJobMapper
StepAngle = LegacyStep(part.Rotation),
StartAngle = part.Rotation.Start,
EndAngle = part.Rotation.End,
+ Allow180Equivalent = part.Rotation.Allow180Equivalent,
};
return drawing;
}
diff --git a/OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs b/OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs
index 632cfd0..c2c7dba 100644
--- a/OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs
+++ b/OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs
@@ -3,9 +3,9 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Fill;
+using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
-using OpenNest.Engine.Jobs.Adapters;
namespace OpenNest.Engine.Jobs.Placement;
/// Constrained-order linear fills in conservative rectangular free regions.
@@ -105,6 +105,8 @@ internal sealed class OrderedPlateNester : IPlateNester
if (policy.Kind == RotationPolicyKind.Fixed)
{
yield return policy.Start;
+ if (policy.Allow180Equivalent)
+ yield return policy.Start + System.Math.PI;
yield break;
}
// A bounded deterministic search, not a proof that an unplaced part cannot fit.
@@ -125,6 +127,8 @@ internal sealed class OrderedPlateNester : IPlateNester
if (angle > policy.End + 1e-9)
yield break;
yield return angle;
+ if (policy.Allow180Equivalent)
+ yield return angle + System.Math.PI;
}
}
}
diff --git a/OpenNest.Engine/Jobs/RotationPolicy.cs b/OpenNest.Engine/Jobs/RotationPolicy.cs
index e714555..0621c83 100644
--- a/OpenNest.Engine/Jobs/RotationPolicy.cs
+++ b/OpenNest.Engine/Jobs/RotationPolicy.cs
@@ -12,7 +12,13 @@ public enum RotationPolicyKind
/// Immutable rotation constraints, in radians about the geometry origin.
public sealed class RotationPolicy
{
- private RotationPolicy(RotationPolicyKind kind, double start, double end, double step)
+ private RotationPolicy(
+ RotationPolicyKind kind,
+ double start,
+ double end,
+ double step,
+ bool allow180Equivalent
+ )
{
if (!double.IsFinite(start) || !double.IsFinite(end) || !double.IsFinite(step))
throw new ArgumentException("Angles must be finite.");
@@ -20,33 +26,53 @@ public sealed class RotationPolicy
Start = start;
End = end;
Step = step;
+ Allow180Equivalent = allow180Equivalent;
}
public RotationPolicyKind Kind { get; }
public double Start { get; }
public double End { get; }
public double Step { get; }
- public static RotationPolicy Automatic { get; } = new(RotationPolicyKind.Automatic, 0, 0, 0);
- public static RotationPolicy Fixed(double angle) =>
- new(RotationPolicyKind.Fixed, angle, angle, 0);
+ /// Whether an orientation 180° from an allowed angle is also legal.
+ public bool Allow180Equivalent { get; }
+ public static RotationPolicy Automatic { get; } =
+ new(RotationPolicyKind.Automatic, 0, 0, 0, false);
- public static RotationPolicy BoundedSweep(double start, double end, double step)
+ public static RotationPolicy Fixed(double angle, bool allow180Equivalent = false) =>
+ new(RotationPolicyKind.Fixed, angle, angle, 0, allow180Equivalent);
+
+ public static RotationPolicy BoundedSweep(
+ double start,
+ double end,
+ double step,
+ bool allow180Equivalent = false
+ )
{
if (step <= 0 || end < start)
throw new ArgumentException("Sweep needs a positive step and ordered bounds.");
- return new RotationPolicy(RotationPolicyKind.BoundedSweep, start, end, step);
+ return new RotationPolicy(
+ RotationPolicyKind.BoundedSweep,
+ start,
+ end,
+ step,
+ allow180Equivalent
+ );
}
/// Preserves the legacy zero-step automatic sentinel; zero never means locked rotation.
public static RotationPolicy FromLegacy(
double stepAngle,
double rotationStart,
- double rotationEnd
- ) => stepAngle == 0 ? Automatic : BoundedSweep(rotationStart, rotationEnd, stepAngle);
+ double rotationEnd,
+ bool allow180Equivalent = false
+ ) =>
+ stepAngle == 0
+ ? Automatic
+ : BoundedSweep(rotationStart, rotationEnd, stepAngle, allow180Equivalent);
- /// True when a placement rotation (radians) satisfies this policy: anything for
- /// Automatic, the fixed angle modulo a full turn, or an exact step inside a bounded sweep.
+ /// True when a placement rotation satisfies this policy. Fixed and bounded
+ /// policies compare orientations modulo full turns; an allowed 180° equivalent is included.
public bool Allows(double rotation)
{
const double epsilon = 0.0000001;
@@ -55,14 +81,33 @@ public sealed class RotationPolicy
if (Kind == RotationPolicyKind.Automatic)
return true;
if (Kind == RotationPolicyKind.Fixed)
+ return AnglesEqual(rotation, Start, epsilon)
+ || (Allow180Equivalent && AnglesEqual(rotation, Start + System.Math.PI, epsilon));
+ return SweepAllows(rotation, epsilon)
+ || (Allow180Equivalent && SweepAllows(rotation - System.Math.PI, epsilon));
+ }
+
+ private bool SweepAllows(double rotation, double epsilon)
+ {
+ var fullTurn = System.Math.PI * 2;
+ var firstTurn = System.Math.Ceiling((Start - epsilon - rotation) / fullTurn);
+ var lastTurn = System.Math.Floor((End + epsilon - rotation) / fullTurn);
+ for (var turns = firstTurn; turns <= lastTurn; turns++)
{
- var delta = (rotation - Start) % (System.Math.PI * 2);
- return System.Math.Abs(delta) <= epsilon
- || System.Math.Abs(System.Math.Abs(delta) - System.Math.PI * 2) <= epsilon;
+ var equivalent = rotation + turns * fullTurn;
+ if (equivalent < Start - epsilon || equivalent > End + epsilon)
+ continue;
+ var steps = (equivalent - Start) / Step;
+ if (System.Math.Abs(steps - System.Math.Round(steps)) <= epsilon)
+ return true;
}
- if (rotation < Start - epsilon || rotation > End + epsilon)
- return false;
- var steps = (rotation - Start) / Step;
- return System.Math.Abs(steps - System.Math.Round(steps)) <= epsilon;
+ return false;
+ }
+
+ private static bool AnglesEqual(double left, double right, double epsilon)
+ {
+ var delta = (left - right) % (System.Math.PI * 2);
+ return System.Math.Abs(delta) <= epsilon
+ || System.Math.Abs(System.Math.Abs(delta) - System.Math.PI * 2) <= epsilon;
}
}
diff --git a/OpenNest.Tests/Benchmark/BenchmarkRunnerTests.cs b/OpenNest.Tests/Benchmark/BenchmarkRunnerTests.cs
index b4a8080..e2eed85 100644
--- a/OpenNest.Tests/Benchmark/BenchmarkRunnerTests.cs
+++ b/OpenNest.Tests/Benchmark/BenchmarkRunnerTests.cs
@@ -1,5 +1,7 @@
using OpenNest.Benchmark;
+using OpenNest.CNC;
using OpenNest.Engine.Jobs;
+using OpenNest.IO;
namespace OpenNest.Tests.Benchmark;
@@ -41,6 +43,51 @@ public sealed class BenchmarkRunnerTests : IDisposable
return JobLoader.Load(manifest);
}
+ private string WriteBaselineNest(
+ string name,
+ double salvageRate = 0.5,
+ bool outsideWorkArea = false,
+ int plateQuantity = 1,
+ int requiredQuantity = 1,
+ int partCount = 1,
+ double partSpacing = 0,
+ double partRotation = 0
+ )
+ {
+ var path = Path.Combine(_dir, name + ".nest");
+ var program = new Program();
+ program.MoveTo(0, 0);
+ program.LineTo(2, 0);
+ program.LineTo(2, 2);
+ program.LineTo(0, 2);
+ program.LineTo(0, 0);
+ var drawing = new Drawing("part", program);
+ if (partRotation != 0)
+ {
+ drawing.Constraints.StepAngle = System.Math.PI / 2;
+ drawing.Constraints.StartAngle = 0;
+ drawing.Constraints.EndAngle = 0;
+ }
+ drawing.Quantity.Required = requiredQuantity;
+ var nest = new Nest(name) { SalvageRate = salvageRate };
+ nest.Drawings.Add(drawing);
+ var plate = new Plate(10, 10) { Quantity = plateQuantity, PartSpacing = partSpacing };
+ for (var index = 0; index < partCount; index++)
+ {
+ var part = Part.CreateAtOrigin(drawing);
+ if (partRotation != 0)
+ part.Rotate(partRotation);
+ part.Location =
+ outsideWorkArea ? new OpenNest.Geometry.Vector(9, 0)
+ : partRotation == 0 ? new OpenNest.Geometry.Vector(index * 2, 0)
+ : new OpenNest.Geometry.Vector(2 + index * 2, 2);
+ plate.Parts.Add(part);
+ }
+ nest.Plates.Add(plate);
+ new NestWriter(nest).Write(path);
+ return path;
+ }
+
private static List FakeEngines(
ConcurrencyProbe probe,
params int[] delaysMs
@@ -56,6 +103,137 @@ public sealed class BenchmarkRunnerTests : IDisposable
)
.ToList();
+ [Fact]
+ public void NestJobUsesSalvageRateStoredInSourceNest()
+ {
+ var job = Assert.Single(JobLoader.Load(WriteBaselineNest("salvage", salvageRate: 0.5)));
+
+ var nestJob = job.BuildNestJob(maxPlates: 1);
+
+ Assert.Equal(0.5, nestJob.Options.SalvageRate);
+ Assert.Equal(0, nestJob.Options.MinimumSalvageDimension);
+ }
+
+ [Fact]
+ public void Run_ScoresTheOriginalBaselineLayoutAlongsideEngines()
+ {
+ var results = BenchmarkRunner.Run(
+ JobLoader.Load(WriteBaselineNest("baseline")),
+ FakeEngines(new ConcurrencyProbe(), 0),
+ maxParallelism: 1
+ );
+
+ var baseline = Assert.Single(results.Where(result => result.EngineName == "Baseline"));
+ Assert.True(baseline.Valid);
+ Assert.Equal(1, baseline.PartsPlaced);
+ Assert.Equal(1, baseline.PartsRequested);
+ Assert.Equal(4, baseline.PlacedArea);
+ Assert.Equal(100, baseline.PlateArea);
+ Assert.Equal(1, baseline.PlatesUsed);
+ Assert.Equal(0.04, baseline.Utilization, 6);
+ Assert.Equal(100, baseline.NetSheetArea);
+ Assert.Equal(100, baseline.Cost);
+ }
+
+ [Fact]
+ public void Run_BaselineUsesSourceSalvageRateUnlessCliOverridesIt()
+ {
+ var jobs = JobLoader.Load(WriteBaselineNest("baseline-salvage", salvageRate: 0.5));
+ var sourceRate = BenchmarkRunner.Run(
+ jobs,
+ FakeEngines(new ConcurrencyProbe(), 0),
+ minimumSalvageDimension: 5,
+ maxParallelism: 1
+ );
+ var overriddenRate = BenchmarkRunner.Run(
+ jobs,
+ FakeEngines(new ConcurrencyProbe(), 0),
+ salvageRate: 0,
+ minimumSalvageDimension: 5,
+ maxParallelism: 1
+ );
+
+ Assert.Equal(
+ 60,
+ Assert.Single(sourceRate.Where(result => result.EngineName == "Baseline")).NetSheetArea
+ );
+ Assert.Equal(
+ 100,
+ Assert
+ .Single(overriddenRate.Where(result => result.EngineName == "Baseline"))
+ .NetSheetArea
+ );
+ }
+
+ [Fact]
+ public void Run_RejectsBaselineLayoutOutsideRotationConstraint()
+ {
+ var results = BenchmarkRunner.Run(
+ JobLoader.Load(
+ WriteBaselineNest("invalid-baseline-rotation", partRotation: System.Math.PI)
+ ),
+ FakeEngines(new ConcurrencyProbe(), 0),
+ maxParallelism: 1
+ );
+
+ var baseline = Assert.Single(results.Where(result => result.EngineName == "Baseline"));
+ Assert.False(baseline.Valid);
+ Assert.Contains(
+ baseline.Violations,
+ violation => violation.Contains("outside its rotation constraint")
+ );
+ }
+
+ [Fact]
+ public void Run_RejectsAnInvalidBaselineLayout()
+ {
+ var results = BenchmarkRunner.Run(
+ JobLoader.Load(WriteBaselineNest("invalid-baseline", outsideWorkArea: true)),
+ FakeEngines(new ConcurrencyProbe(), 0),
+ maxParallelism: 1
+ );
+
+ var baseline = Assert.Single(results.Where(result => result.EngineName == "Baseline"));
+ Assert.False(baseline.Valid);
+ Assert.Equal(0, baseline.Utilization);
+ Assert.Contains(
+ baseline.Violations,
+ violation => violation.Contains("outside the work area")
+ );
+ }
+
+ [Fact]
+ public void Run_DoesNotScoreZeroQuantityBaselinePlate()
+ {
+ var results = BenchmarkRunner.Run(
+ JobLoader.Load(WriteBaselineNest("zero-quantity", plateQuantity: 0)),
+ FakeEngines(new ConcurrencyProbe(), 0),
+ maxParallelism: 1
+ );
+
+ Assert.DoesNotContain(results, result => result.EngineName == "Baseline");
+ }
+
+ [Fact]
+ public void Run_AppliesSpacingOverrideToBaseline()
+ {
+ var results = BenchmarkRunner.Run(
+ JobLoader.Load(
+ WriteBaselineNest("spacing-override", requiredQuantity: 2, partCount: 2),
+ partSpacingOverride: 0.25
+ ),
+ FakeEngines(new ConcurrencyProbe(), 0),
+ maxParallelism: 1
+ );
+
+ var baseline = Assert.Single(results.Where(result => result.EngineName == "Baseline"));
+ Assert.False(baseline.Valid);
+ Assert.Contains(
+ baseline.Violations,
+ violation => violation.Contains("closer than the required spacing")
+ );
+ }
+
[Fact]
public void Run_NeverExceedsMaxParallelism_ButReachesIt()
{
diff --git a/README.md b/README.md
index 4dbccc1..94fb72c 100644
--- a/README.md
+++ b/README.md
@@ -357,7 +357,11 @@ to `0`, which also disables credit. With both enabled, only the largest qualifyi
full-span edge rectangle outside placed bounding boxes plus part spacing is credited,
within the usable work area; both dimensions must meet the minimum in job units.
Holes/scraps are not credited. No cut-off toolpath, kerf, handling, or future-demand
-valuation is modeled. Benchmark ranking credits the same estimate (see Benchmarking Nest Engines).
+valuation is modeled. Benchmark ranking credits the same estimate (see Benchmarking Nest
+Engines). For a `.nest` benchmark input, the saved `Nest.SalvageRate` is the benchmark
+default unless `--salvage-rate` overrides it; manifests default to `0`. A positive
+`--min-salvage-dimension` is still required for any credit, and the console warns when a
+nonzero rate would otherwise be disabled.
This is a tested deterministic heuristic baseline, **not an optimal or production-
certified solver**. Conservative rectangular free-region hints and linear fills can
@@ -370,6 +374,13 @@ Geometry acceptance remains strict, including open marks leaving closed material
Benchmark export example (use a separate output directory):
+For `.nest` inputs the report includes a `Baseline` row before the engine rows. It validates
+the saved authored plates (including positive plate repeat quantities) with the same
+bounds, spacing, quantity, and rotation checks as generated layouts; it is not timed as
+an engine solve. Its score uses the same salvage-credit and unplaced-part-cost calculation,
+with `--spacing` and salvage CLI overrides applied without mutating the source. Manifest
+inputs have no baseline row.
+
```bash
dotnet run --project OpenNest.Benchmark -- input.nest \
--engines StockLadder --sheet-sizes 48x96,48x120,48x144,60x96,60x120,60x144,72x96,72x120,72x144 \