From 2dab9232593d4fa9acd3fd64630d8b6632cf395b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 31 Oct 2026 08:52:17 -0400 Subject: [PATCH] feat(benchmark): accept rotation sets in manifests A manifest part may list "rotations" in degrees: [0] locks it, [0, 180] locks it at 0 with the half turn allowed (Allow180Equivalent), and [0, 90, 180, 270] is the automatic quarter-turn default. Any other set, or "rotations" together with "allowRotation": false, is an error naming the part. Without it, "allowRotation" behaves as before. Lets converted external benchmark jobs (Nestbolt's 0/180 garment and box jobs) keep their own rotation rules instead of being widened to quarter turns. --- OpenNest.Benchmark/DxfManifestLoader.cs | 42 +++++++++++- .../Benchmark/JobLoaderManifestTests.cs | 66 +++++++++++++++++++ docs/performance/engine-benchmarking.md | 2 +- 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/OpenNest.Benchmark/DxfManifestLoader.cs b/OpenNest.Benchmark/DxfManifestLoader.cs index 302216f1..c119b8e7 100644 --- a/OpenNest.Benchmark/DxfManifestLoader.cs +++ b/OpenNest.Benchmark/DxfManifestLoader.cs @@ -135,12 +135,14 @@ namespace OpenNest.Benchmark throw new InvalidOperationException($"Failed to import DXF: {dxfPath}"); // A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly. - if (!part.AllowRotation) + var rotations = RotationSet(part); + if (rotations != RotationChoice.QuarterTurns) { drawing.Constraints ??= new NestConstraints(); drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI; drawing.Constraints.StartAngle = 0; drawing.Constraints.EndAngle = 0; + drawing.Constraints.Allow180Equivalent = rotations == RotationChoice.HalfTurns; } var constraints = drawing.Constraints; @@ -156,6 +158,43 @@ namespace OpenNest.Benchmark }; } + private enum RotationChoice + { + None, + HalfTurns, + QuarterTurns, + } + + /// + /// A part's "rotations" (degrees) must be {0}, {0, 180} or {0, 90, 180, 270}; without it, + /// "allowRotation" picks quarter turns (true, the default) or none. + /// + private static RotationChoice RotationSet(ManifestPart part) + { + if (part.Rotations == null) + return part.AllowRotation ? RotationChoice.QuarterTurns : RotationChoice.None; + if (!part.AllowRotation) + throw new InvalidOperationException( + $"Manifest part '{part.Dxf}': set \"rotations\" or \"allowRotation\", not both." + ); + + var set = part.Rotations + .Select(degrees => ((degrees % 360) + 360) % 360) + .Distinct() + .OrderBy(degrees => degrees) + .ToArray(); + if (set.SequenceEqual(new[] { 0.0 })) + return RotationChoice.None; + if (set.SequenceEqual(new[] { 0.0, 180.0 })) + return RotationChoice.HalfTurns; + if (set.SequenceEqual(new[] { 0.0, 90.0, 180.0, 270.0 })) + return RotationChoice.QuarterTurns; + throw new InvalidOperationException( + $"Manifest part '{part.Dxf}': \"rotations\" must be [0], [0, 180] or " + + $"[0, 90, 180, 270] (was [{string.Join(", ", part.Rotations)}])." + ); + } + private class Manifest { public List SheetSizes { get; set; } @@ -170,6 +209,7 @@ namespace OpenNest.Benchmark public string Dxf { get; set; } public int Quantity { get; set; } public bool AllowRotation { get; set; } = true; + public List Rotations { get; set; } } } } diff --git a/OpenNest.Tests/Benchmark/JobLoaderManifestTests.cs b/OpenNest.Tests/Benchmark/JobLoaderManifestTests.cs index 643f4a62..374755f9 100644 --- a/OpenNest.Tests/Benchmark/JobLoaderManifestTests.cs +++ b/OpenNest.Tests/Benchmark/JobLoaderManifestTests.cs @@ -202,6 +202,72 @@ public sealed class JobLoaderManifestTests : IDisposable Assert.Equal(0, locked.End); } + private RotationPolicy RotationOf(string rotationsJson) + { + var path = WriteManifest( + "bench.json", + $$""" + { + "sheetSizes": ["48x96"], + "parts": [ { "dxf": "parts/plate.dxf", "quantity": 2, "rotations": {{rotationsJson}} } ] + } + """ + ); + return Assert.Single(JobLoader.Load(path)).BuildNestJob(maxPlates: 4).Parts[0].Rotation; + } + + [Fact] + public void Rotations_HalfTurnsLockAtZeroAndAllowTheHalfTurn() + { + var policy = RotationOf("[180, 0]"); + + Assert.Equal(RotationPolicyKind.BoundedSweep, policy.Kind); + Assert.True(policy.Allow180Equivalent); + Assert.Equal(new[] { 0, System.Math.PI }, policy.EnumerateAngles()); + } + + [Fact] + public void Rotations_ZeroOnlyLocksAndQuarterTurnsAreAutomatic() + { + var locked = RotationOf("[0]"); + Assert.Equal(RotationPolicyKind.BoundedSweep, locked.Kind); + Assert.False(locked.Allow180Equivalent); + Assert.Equal(new[] { 0.0 }, locked.EnumerateAngles()); + + Assert.Equal(RotationPolicyKind.Automatic, RotationOf("[0, 90, 180, 270]").Kind); + Assert.Equal(RotationPolicyKind.Automatic, RotationOf("[0, -90, 450, 180]").Kind); + } + + [Theory] + [InlineData("[0, 90]")] + [InlineData("[45]")] + [InlineData("[]")] + public void Rotations_OtherSetsThrowNamingTheEntry(string rotations) + { + var error = Assert.Throws(() => RotationOf(rotations)); + + Assert.Contains("parts/plate.dxf", error.Message); + Assert.Contains("rotations", error.Message); + } + + [Fact] + public void Rotations_WithAllowRotationFalseThrows() + { + var path = WriteManifest( + "bench.json", + """ + { + "sheetSizes": ["48x96"], + "parts": [ + { "dxf": "parts/plate.dxf", "quantity": 2, "allowRotation": false, "rotations": [0] } + ] + } + """ + ); + + Assert.Throws(() => JobLoader.Load(path)); + } + [Fact] public void Folder_LoadsManifestSuffixedFilesAndIgnoresOtherJson() { diff --git a/docs/performance/engine-benchmarking.md b/docs/performance/engine-benchmarking.md index c5378436..8060aa12 100644 --- a/docs/performance/engine-benchmarking.md +++ b/docs/performance/engine-benchmarking.md @@ -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 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 ` 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 allows quarter turns unless `"allowRotation": false` locks it at 0°, or `"rotations"` lists its 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 ` 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.