mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-04 10:11:37 -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).
323 lines
10 KiB
C#
323 lines
10 KiB
C#
using OpenNest.Benchmark;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.Engine.Jobs;
|
|
|
|
namespace OpenNest.Tests.Benchmark;
|
|
|
|
public sealed class JobLoaderManifestTests : IDisposable
|
|
{
|
|
private static readonly string SourceDxf = Path.Combine(
|
|
"Bending",
|
|
"TestData",
|
|
"4526 A14 PT11.dxf"
|
|
);
|
|
|
|
private readonly string _dir = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"opennest-manifest-" + Guid.NewGuid().ToString("N")
|
|
);
|
|
|
|
public JobLoaderManifestTests()
|
|
{
|
|
Directory.CreateDirectory(Path.Combine(_dir, "parts"));
|
|
File.Copy(SourceDxf, Path.Combine(_dir, "parts", "bracket.dxf"));
|
|
File.Copy(SourceDxf, Path.Combine(_dir, "parts", "plate.dxf"));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try
|
|
{
|
|
Directory.Delete(_dir, recursive: true);
|
|
}
|
|
catch (IOException) { }
|
|
}
|
|
|
|
private string WriteManifest(string name, string json)
|
|
{
|
|
var path = Path.Combine(_dir, name);
|
|
File.WriteAllText(path, json);
|
|
return path;
|
|
}
|
|
|
|
private const string ValidManifest = """
|
|
{
|
|
"sheetSizes": ["48x96", "60x120"],
|
|
"spacing": 0.25,
|
|
"edgeSpacing": 0.5,
|
|
"quadrant": 2,
|
|
"parts": [
|
|
{ "dxf": "parts/bracket.dxf", "quantity": 12 },
|
|
{ "dxf": "parts/plate.dxf", "quantity": 4 }
|
|
]
|
|
}
|
|
""";
|
|
|
|
[Fact]
|
|
public void Manifest_ImportsDxfsWithQuantitiesAndJobSettings()
|
|
{
|
|
var path = WriteManifest("bench.json", ValidManifest);
|
|
|
|
var job = Assert.Single(JobLoader.Load(path));
|
|
|
|
Assert.Equal("bench", job.Name);
|
|
Assert.Equal(new[] { 12, 4 }, job.Requests.Select(r => r.Quantity));
|
|
Assert.Equal(16, job.TotalRequestedQuantity);
|
|
Assert.All(job.Requests, r => Assert.NotEmpty(r.Drawing.Program.Codes));
|
|
Assert.Equal(new[] { new Size(48, 96), new Size(60, 120) }, job.CandidateSizes.ToArray());
|
|
Assert.Equal(0.25, job.PartSpacing);
|
|
Assert.Equal(0.5, job.EdgeSpacing.Left);
|
|
Assert.Equal(0.5, job.EdgeSpacing.Top);
|
|
Assert.Equal(2, job.Quadrant);
|
|
}
|
|
|
|
[Fact]
|
|
public void Manifest_DxfPathsResolveRelativeToTheManifestNotTheWorkingDirectory()
|
|
{
|
|
var path = WriteManifest("bench.json", ValidManifest);
|
|
var elsewhere = Directory.GetCurrentDirectory();
|
|
Assert.NotEqual(_dir, elsewhere);
|
|
|
|
var job = Assert.Single(JobLoader.Load(path));
|
|
|
|
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()
|
|
{
|
|
var path = WriteManifest("bench.json", ValidManifest);
|
|
|
|
var job = Assert.Single(JobLoader.Load(path, new[] { new Size(10, 20) }, 0.75));
|
|
|
|
Assert.Equal(new[] { new Size(10, 20) }, job.CandidateSizes.ToArray());
|
|
Assert.Equal(0.75, job.PartSpacing);
|
|
}
|
|
|
|
[Fact]
|
|
public void SheetSizesFromOverrideAreEnoughWhenManifestOmitsThem()
|
|
{
|
|
var path = WriteManifest(
|
|
"bench.json",
|
|
"""{ "parts": [ { "dxf": "parts/bracket.dxf", "quantity": 1 } ] }"""
|
|
);
|
|
|
|
var job = Assert.Single(JobLoader.Load(path, new[] { new Size(48, 96) }));
|
|
|
|
Assert.Equal(new[] { new Size(48, 96) }, job.CandidateSizes.ToArray());
|
|
Assert.Equal(0, job.PartSpacing);
|
|
Assert.Equal(1, job.Quadrant);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoSheetSizesAnywhere_ThrowsAndMentionsSheetSizes()
|
|
{
|
|
var path = WriteManifest(
|
|
"bench.json",
|
|
"""{ "parts": [ { "dxf": "parts/bracket.dxf", "quantity": 1 } ] }"""
|
|
);
|
|
|
|
var ex = Assert.Throws<InvalidOperationException>(() => JobLoader.Load(path));
|
|
|
|
Assert.Contains("sheet size", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnparseableSheetSize_ThrowsNamingTheValue()
|
|
{
|
|
var path = WriteManifest(
|
|
"bench.json",
|
|
"""{ "sheetSizes": ["huge"], "parts": [ { "dxf": "parts/bracket.dxf", "quantity": 1 } ] }"""
|
|
);
|
|
|
|
var ex = Assert.Throws<InvalidOperationException>(() => JobLoader.Load(path));
|
|
|
|
Assert.Contains("huge", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void MissingDxf_ThrowsNamingTheEntry()
|
|
{
|
|
var path = WriteManifest(
|
|
"bench.json",
|
|
"""{ "sheetSizes": ["48x96"], "parts": [ { "dxf": "parts/nope.dxf", "quantity": 1 } ] }"""
|
|
);
|
|
|
|
var ex = Assert.Throws<FileNotFoundException>(() => JobLoader.Load(path));
|
|
|
|
Assert.Contains("nope.dxf", ex.Message);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(-3)]
|
|
public void NonPositiveQuantity_ThrowsNamingTheEntry(int quantity)
|
|
{
|
|
var path = WriteManifest(
|
|
"bench.json",
|
|
$$"""{ "sheetSizes": ["48x96"], "parts": [ { "dxf": "parts/bracket.dxf", "quantity": {{quantity}} } ] }"""
|
|
);
|
|
|
|
var ex = Assert.Throws<InvalidOperationException>(() => JobLoader.Load(path));
|
|
|
|
Assert.Contains("bracket.dxf", ex.Message);
|
|
Assert.Contains("quantity", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyPartsList_Throws()
|
|
{
|
|
var path = WriteManifest("bench.json", """{ "sheetSizes": ["48x96"], "parts": [] }""");
|
|
|
|
var ex = Assert.Throws<InvalidOperationException>(() => JobLoader.Load(path));
|
|
|
|
Assert.Contains("parts", ex.Message, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
[Fact]
|
|
public void AllowRotationFalse_LocksThatPartsRotationInTheNestJob()
|
|
{
|
|
var path = WriteManifest(
|
|
"bench.json",
|
|
"""
|
|
{
|
|
"sheetSizes": ["48x96"],
|
|
"parts": [
|
|
{ "dxf": "parts/bracket.dxf", "quantity": 2 },
|
|
{ "dxf": "parts/plate.dxf", "quantity": 2, "allowRotation": false }
|
|
]
|
|
}
|
|
"""
|
|
);
|
|
|
|
var job = Assert.Single(JobLoader.Load(path));
|
|
var nestJob = job.BuildNestJob(maxPlates: 4);
|
|
|
|
Assert.Equal(RotationPolicyKind.Automatic, nestJob.Parts[0].Rotation.Kind);
|
|
|
|
// The legacy lock (step 2π, start = end = 0) reaches the engine as a single-angle sweep at 0.
|
|
var locked = nestJob.Parts[1].Rotation;
|
|
Assert.Equal(RotationPolicyKind.BoundedSweep, locked.Kind);
|
|
Assert.Equal(0, locked.Start);
|
|
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_ZeroOnlyLocksAndQuarterTurnsAllowOnlyQuarterTurns()
|
|
{
|
|
var locked = RotationOf("[0]");
|
|
Assert.Equal(RotationPolicyKind.BoundedSweep, locked.Kind);
|
|
Assert.False(locked.Allow180Equivalent);
|
|
Assert.Equal(new[] { 0.0 }, locked.EnumerateAngles());
|
|
|
|
// Automatic rotation would allow any angle; quarter turns must not.
|
|
foreach (var set in new[] { "[0, 90, 180, 270]", "[0, -90, 450, 180]" })
|
|
{
|
|
var quarter = RotationOf(set);
|
|
Assert.Equal(RotationPolicyKind.BoundedSweep, quarter.Kind);
|
|
Assert.Equal(
|
|
new[] { 0, System.Math.PI / 2, System.Math.PI, 3 * System.Math.PI / 2 },
|
|
quarter.EnumerateAngles()
|
|
);
|
|
Assert.True(quarter.Allows(3 * System.Math.PI / 2));
|
|
Assert.False(quarter.Allows(0.1));
|
|
Assert.False(quarter.Allows(4.884));
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("[0, 90]")]
|
|
[InlineData("[45]")]
|
|
[InlineData("[]")]
|
|
public void Rotations_OtherSetsThrowNamingTheEntry(string rotations)
|
|
{
|
|
var error = Assert.Throws<InvalidOperationException>(() => 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<InvalidOperationException>(() => JobLoader.Load(path));
|
|
}
|
|
|
|
[Fact]
|
|
public void Folder_LoadsManifestSuffixedFilesAndIgnoresOtherJson()
|
|
{
|
|
WriteManifest("a.manifest.json", ValidManifest);
|
|
WriteManifest("b.manifest.json", ValidManifest);
|
|
WriteManifest("results.json", """{ "not": "a manifest" }""");
|
|
|
|
var jobs = JobLoader.Load(_dir);
|
|
|
|
Assert.Equal(new[] { "a.manifest", "b.manifest" }, jobs.Select(j => j.Name));
|
|
}
|
|
}
|