From 8e4735d25da8de3a7a75bbf286b7baaf97e2b858 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 22 Sep 2026 16:18:02 -0400 Subject: [PATCH] [verified] add benchmark baseline and rotation fixes --- OpenNest.Benchmark/BenchmarkJob.cs | 19 +- OpenNest.Benchmark/BenchmarkRunner.cs | 172 ++++++++++++++++- OpenNest.Benchmark/JobLoader.cs | 31 +++ OpenNest.Benchmark/Program.cs | 23 ++- .../Jobs/NestJobGeometryTests.cs | 90 ++++++++- .../Jobs/PlateNesterParityTests.cs | 20 +- .../Jobs/Adapters/DrawingJobMapper.cs | 11 +- .../Jobs/Placement/OrderedPlateNester.cs | 6 +- OpenNest.Engine/Jobs/RotationPolicy.cs | 79 ++++++-- .../Benchmark/BenchmarkRunnerTests.cs | 178 ++++++++++++++++++ README.md | 13 +- 11 files changed, 599 insertions(+), 43 deletions(-) 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 \