feat(engine): add built-in Rectangles and Irregular nesting engines
Moves the two production plug-in engines into OpenNest.Engine under names that describe the jobs they suit: - Rectangles: plain and near-rectangular plates, maximal-rectangles box packing (was the RectanglesNestingEngine plug-in) - Irregular: irregular profiles, no-fit-polygon frontier packing (was the Opus55NestingEngine plug-in) Their tests and the shared engine contract/layout test kit move into OpenNest.Engine.Tests/NestingEngines. The registry maps the old plug-in names to the new engines, so saved desktop selections, scripts and API requests keep working, and a leftover plug-in DLL under an old name cannot shadow its replacement. Desktop startup passes the registry's lookup when restoring the saved Auto Nest engine.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
using Xunit;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
@@ -10,12 +10,45 @@ public class NestingEngineRegistryTests
|
||||
{
|
||||
var names = NestingEngineRegistry.AvailableEngines.Select(e => e.Name).ToList();
|
||||
|
||||
Assert.Contains("Rectangles", names);
|
||||
Assert.Contains("Irregular", names);
|
||||
Assert.Contains("Default", names);
|
||||
Assert.Contains("Strip", names);
|
||||
Assert.Contains("Vertical Remnant", names);
|
||||
Assert.Contains("Horizontal Remnant", names);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenamedPlugInNamesResolveToTheirBuiltInReplacements()
|
||||
{
|
||||
Assert.Equal("Irregular", NestingEngineRegistry.ResolveName("Opus55NestingEngine"));
|
||||
Assert.Equal("Rectangles", NestingEngineRegistry.ResolveName("rectanglesnestingengine"));
|
||||
Assert.Equal("Rectangles", NestingEngineRegistry.ResolveName(" rectangles "));
|
||||
Assert.IsType<OpenNest.Engine.NestingEngines.Irregular.IrregularNestingEngine>(
|
||||
NestingEngineRegistry.Create("Opus55NestingEngine"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetiredEnginesAreNotSilentlyAliased()
|
||||
{
|
||||
Assert.Null(NestingEngineRegistry.ResolveName("Gpt6AstraNestingEngine"));
|
||||
Assert.Null(NestingEngineRegistry.ResolveName("Qwen38FlashNextNestingEngine"));
|
||||
Assert.Null(NestingEngineRegistry.ResolveName(" "));
|
||||
Assert.Throws<NotSupportedException>(() => NestingEngineRegistry.Create("Qwen38FlashNextNestingEngine"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeftoverPlugInUnderARenamedNameCannotShadowItsReplacement()
|
||||
{
|
||||
var before = NestingEngineRegistry.AvailableEngines.Count;
|
||||
|
||||
NestingEngineRegistry.Register("Opus55NestingEngine", "stale plug-in",
|
||||
() => new FixedStrategyNestingEngine("Default"));
|
||||
|
||||
Assert.Equal(before, NestingEngineRegistry.AvailableEngines.Count);
|
||||
Assert.Equal("Irregular", NestingEngineRegistry.ResolveName("Opus55NestingEngine"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EachBuiltInFactoryProducesAWorkingEngine()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
using OpenNest.Engine.Jobs;
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
/// <summary>
|
||||
/// Curved extremes (discs, rings, obrounds) and sloped ones (triangles) must clear the layout check
|
||||
/// at every spacing: the check circumscribes arcs, so box-touching copies are only valid when the
|
||||
/// catalog reads their boxes the way the check does. Failed at 16-50 of these 80 jobs before that.
|
||||
/// </summary>
|
||||
public class CurvedExtremeSweepTests
|
||||
{
|
||||
[Fact]
|
||||
public void CurvedAndSlopedPartsPassTheLayoutCheckAtEverySpacing()
|
||||
{
|
||||
var bad = new List<string>(); var n = 0;
|
||||
foreach (var r in new[] { 0.37, 0.5, 0.731, 1.0, 1.23, 2.0, 3.3, 5.0, 7.77, 12.0 })
|
||||
foreach (var sp in new[] { 0.0, 0.1, 0.25, 0.3125 })
|
||||
foreach (var rot in new[] { false, true })
|
||||
foreach (var ring in new[] { false, true })
|
||||
{
|
||||
var pol = rot ? RotationPolicy.Automatic : RotationPolicy.Fixed(0);
|
||||
var job = Job(new[] { Part("d", ring ? Ring(2 * r, r) : Disc(r), 12, pol), Part("o", Obround(4 * r, 1.3 * r), 6, pol), Part("t", Triangle(3 * r, 2 * r), 4, pol) },
|
||||
new[] { Stock("s", 12 * r + 5, 14 * r + 5, spacing: sp) });
|
||||
var res = new RectanglesNestingEngine().Solve(job); n++;
|
||||
var v = NestLayoutCheck.Violations(job, res);
|
||||
if (v.Count > 0) bad.Add($"r={r} sp={sp} rot={rot} ring={ring}: {v[0]}");
|
||||
}
|
||||
Assert.True(bad.Count == 0, $"{bad.Count}/{n}\n" + string.Join("\n", bad));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using Xunit;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
/// <summary>Host contract only; engines retain their own packing-quality regressions.</summary>
|
||||
public abstract class EngineContractTests<TEngine> where TEngine : INestingEngine, new()
|
||||
{
|
||||
[Fact]
|
||||
public void ContractPublicConstructor() => Assert.IsAssignableFrom<INestingEngine>(Activator.CreateInstance(typeof(TEngine)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void ContractQuadrants(int quadrant)
|
||||
{
|
||||
var job = Job([Part("disc", Disc(2), 3), Part("ell", LShape(6, 5, 2), 3)],
|
||||
[Stock("s", 20, 30, 0.2, new Spacing(0.2, 0.3, 0.4, 0.5), quadrant)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractOverflowIndicesAndProgress()
|
||||
{
|
||||
var job = Job([Part("p", Rectangle(8, 8), 3)], [Stock("s", 10, 10)]);
|
||||
var commits = new List<NestJobProgress>();
|
||||
var result = new TEngine().Solve(job, new Capture(p => { if (p.Stage == NestJobStage.PlateCommitted) commits.Add(p); }));
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(3, result.Plates.Count);
|
||||
Assert.Equal(Enumerable.Range(0, 3), result.Plates.Select(p => p.PlateIndex));
|
||||
Assert.Equal(3, commits.Count);
|
||||
Assert.Equal(Enumerable.Range(0, 3), commits.Select(p => p.PlateIndex));
|
||||
Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedPlates));
|
||||
Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedParts));
|
||||
Assert.All(result.StockUsage, s => Assert.Null(s.Remaining));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractOversize()
|
||||
{
|
||||
var job = Job([Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(2, 2), 2)], [Stock("s", 10, 10)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractLowerNumberPriorityWins()
|
||||
{
|
||||
var job = Job([Part("low", Rectangle(8, 8), 1, priority: 9), Part("high", Rectangle(8, 8), 1, priority: 0)],
|
||||
[Stock("s", 10, 10, quantity: 1)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal("high", Assert.Single(Assert.Single(result.Plates).Placements).PartId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractEtchOutsideSheetIsIgnored()
|
||||
{
|
||||
var etched = NotchedPartWithEtch();
|
||||
etched.Codes.Add(new RapidMove(5, 5));
|
||||
etched.Codes.Add(new LinearMove(100, 100) { Layer = LayerType.Scribe });
|
||||
var job = Job([Part("p", etched, 1, RotationPolicy.Fixed(0))], [Stock("s", 10.4, 10.4, quantity: 1)]);
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractDeterminism()
|
||||
{
|
||||
NestJob Build() => Job([Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12)],
|
||||
[Stock("a", 30, 45, 0.3), Stock("b", 40, 40, 0.3)]);
|
||||
var engine = new TEngine();
|
||||
var job = Build();
|
||||
var first = engine.Solve(job);
|
||||
var second = engine.Solve(job);
|
||||
var third = new TEngine().Solve(Build());
|
||||
foreach (var result in new[] { first, second, third }) LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(Describe(first), Describe(second));
|
||||
Assert.Equal(Describe(first), Describe(third));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractCancellationThrows()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
var job = Job([Part("p", Rectangle(2, 2), 5)], [Stock("s", 10, 10)]);
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => new TEngine().Solve(job, token: cancellation.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContractCancellationDuringSolveThrows()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var job = Job([Part("p", Rectangle(2, 2), 20)], [Stock("s", 10, 10)]);
|
||||
var progress = new Capture(p =>
|
||||
{
|
||||
if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel();
|
||||
});
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => new TEngine().Solve(job, progress, cancellation.Token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public void ContractStockAndPlateLimits(bool plateLimit)
|
||||
{
|
||||
var job = Job([Part("p", Rectangle(8, 8), 3)],
|
||||
[Stock("s", 10, 10, quantity: plateLimit ? null : 1)],
|
||||
new NestJobOptions(maxPlates: plateLimit ? 1 : null));
|
||||
var result = new TEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(plateLimit ? NestJobStopReason.PlateLimitReached : NestJobStopReason.StockExhausted, result.StopReason);
|
||||
}
|
||||
|
||||
private static string Describe(NestJobResult result) => System.Text.Json.JsonSerializer.Serialize(result);
|
||||
private sealed class Capture(Action<NestJobProgress> action) : IProgress<NestJobProgress>
|
||||
{ public void Report(NestJobProgress value) => action(value); }
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public class IrregularNestingEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void RectanglesFitOnOneSheetWithSpacing()
|
||||
{
|
||||
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(12, result.Plates[0].Placements.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
|
||||
{
|
||||
var job = Job(
|
||||
new[]
|
||||
{
|
||||
Part("disc", Disc(3), 10),
|
||||
Part("ell", LShape(12, 8, 4), 10),
|
||||
Part("tri", Triangle(9, 6), 10),
|
||||
Part("slot", Obround(10, 3), 6),
|
||||
},
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZeroSpacingStillKeepsPartsApartForValidation()
|
||||
{
|
||||
var job = Job(new[] { Part("disc", Disc(2), 30), Part("rect", Rectangle(7, 3), 20) }, new[] { Stock("sheet", 30, 40) });
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LargeAndSmallConcavePartsShareASheet()
|
||||
{
|
||||
// End-to-end companion to NoFitCacheTests' containment cases (the precise regression guard).
|
||||
var job = Job(
|
||||
new[] { Part("small", LShape(3, 3, 1), 6), Part("big", Rectangle(20, 20), 2) },
|
||||
new[] { Stock("sheet", 25, 45, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PicksTheCheaperSheetWhenItHoldsEverything()
|
||||
{
|
||||
var job = Job(
|
||||
new[] { Part("square", Rectangle(10, 10), 4) },
|
||||
new[] { Stock("big", 60, 120, spacing: 0.25), Stock("small", 25, 25, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("small", Assert.Single(result.Plates).StockId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpillsOntoAdditionalSheets()
|
||||
{
|
||||
var job = Job(new[] { Part("rect", Rectangle(20, 10), 25) }, new[] { Stock("sheet", 30, 50, spacing: 0.5) });
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.True(result.Plates.Count > 1);
|
||||
Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count));
|
||||
var indices = result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex).OrderBy(i => i);
|
||||
Assert.Equal(Enumerable.Range(0, 25), indices);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RespectsFixedAndBoundedRotationPolicies()
|
||||
{
|
||||
var fixedPolicy = RotationPolicy.Fixed(0);
|
||||
var sweep = RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4);
|
||||
var job = Job(
|
||||
new[]
|
||||
{
|
||||
Part("fixed", LShape(10, 6, 3), 8, fixedPolicy),
|
||||
Part("swept", Triangle(8, 5), 8, sweep),
|
||||
},
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
|
||||
{
|
||||
var policy = placement.PartId == "fixed" ? fixedPolicy : sweep;
|
||||
Assert.True(policy.Allows(placement.Rotation), $"{placement.PartId} at {placement.Rotation}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OversizedPartIsReportedUnplacedWithoutBlockingOthers()
|
||||
{
|
||||
var job = Job(
|
||||
new[] { Part("huge", Rectangle(100, 100), 1), Part("small", Rectangle(5, 5), 3) },
|
||||
new[] { Stock("sheet", 20, 20) }
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced);
|
||||
Assert.Equal(3, result.Fulfillment.Single(f => f.PartId == "small").Placed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopsWhenFiniteStockRunsOut()
|
||||
{
|
||||
var job = Job(new[] { Part("rect", Rectangle(9, 9), 20) }, new[] { Stock("sheet", 20, 20, quantity: 2) });
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
|
||||
Assert.Equal(2, result.Plates.Count);
|
||||
var usage = Assert.Single(result.StockUsage);
|
||||
Assert.Equal(2, usage.Used);
|
||||
Assert.Equal(0, usage.Remaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HonorsMaxPlates()
|
||||
{
|
||||
var job = Job(
|
||||
new[] { Part("rect", Rectangle(9, 9), 20) },
|
||||
new[] { Stock("sheet", 20, 20) },
|
||||
new NestJobOptions(maxPlates: 1)
|
||||
);
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsDeterministic()
|
||||
{
|
||||
NestJob Build() =>
|
||||
Job(
|
||||
new[] { Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12) },
|
||||
new[] { Stock("a", 30, 45, spacing: 0.3), Stock("b", 40, 40, spacing: 0.3) }
|
||||
);
|
||||
|
||||
var first = new IrregularNestingEngine().Solve(Build());
|
||||
var second = new IrregularNestingEngine().Solve(Build());
|
||||
|
||||
Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EtchMarksAreLeftOutOfNestingGeometry()
|
||||
{
|
||||
// A bend tick starts on material and ends 1.0 into a side notch, outside the part but
|
||||
// inside its bounding box (the PEP case that crashed nesting before 1b5e1b1). As
|
||||
// material it is open geometry leaving the part; as a mark it must be ignored.
|
||||
var etched = Polyline((0, 0), (10, 0), (10, 4), (8, 4), (8, 6), (10, 6), (10, 10), (0, 10));
|
||||
etched.Codes.Add(new RapidMove(7.5, 5));
|
||||
etched.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe });
|
||||
var job = Job(new[] { Part("part", etched, 2, RotationPolicy.Fixed(0)) }, new[] { Stock("sheet", 10.4, 20.6, spacing: 0.2) });
|
||||
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPublicParameterlessConstructorForPluginDiscovery()
|
||||
{
|
||||
var engine = Activator.CreateInstance(typeof(IrregularNestingEngine));
|
||||
Assert.IsAssignableFrom<INestingEngine>(engine);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed class IrregularContractTests : EngineContractTests<IrregularNestingEngine> { }
|
||||
@@ -0,0 +1,69 @@
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using System.Linq;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public class IrregularNoFitCacheTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(0.0, 0.0)] // B's corner at A's corner: B covers A completely.
|
||||
[InlineData(-5.0, -5.0)] // A deep inside B.
|
||||
[InlineData(2.0, 0.5)] // Partial overlap.
|
||||
public void ForbidsEveryOverlappingOffsetIncludingContainment(double dx, double dy)
|
||||
{
|
||||
var (small, big) = Orientations();
|
||||
var nfp = new NoFitCache(0.1).Get(small, big);
|
||||
|
||||
Assert.True(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be forbidden");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(4.0, 0.0)] // Beside A, clear by more than the clearance.
|
||||
[InlineData(0.0, -21.0)] // Below A.
|
||||
[InlineData(-21.0, 0.0)] // Left of A.
|
||||
public void AllowsClearOffsets(double dx, double dy)
|
||||
{
|
||||
var (small, big) = Orientations();
|
||||
var nfp = new NoFitCache(0.1).Get(small, big);
|
||||
|
||||
Assert.False(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be free");
|
||||
}
|
||||
|
||||
/// <summary>A = 3x3 L (concave), B = 20x20 square; both at rotation 0 with origin at the lower-left.</summary>
|
||||
private static (Orientation Small, Orientation Big) Orientations()
|
||||
{
|
||||
var job = new NestJob(
|
||||
new[]
|
||||
{
|
||||
new NestJobPart("small", Snapshot((0, 0), (3, 0), (3, 1), (1, 1), (1, 3), (0, 3)), 1, 0, RotationPolicy.Fixed(0)),
|
||||
new NestJobPart("big", Snapshot((0, 0), (20, 0), (20, 20), (0, 20)), 1, 0, RotationPolicy.Fixed(0)),
|
||||
},
|
||||
new[] { new NestPlateStock("s", new Size(100, 100)) }
|
||||
);
|
||||
var types = PartCatalog.Build(job);
|
||||
return (types[0].Orientations.Single(), types[1].Orientations.Single());
|
||||
}
|
||||
|
||||
private static bool Forbidden(Nfp nfp, PointD point)
|
||||
{
|
||||
var winding = 0;
|
||||
foreach (var path in nfp.Region)
|
||||
if (Clipper.PointInPolygon(point, path) == PointInPolygonResult.IsInside)
|
||||
winding += Clipper.IsPositive(path) ? 1 : -1;
|
||||
return winding != 0;
|
||||
}
|
||||
|
||||
private static PartGeometrySnapshot Snapshot(params (double X, double Y)[] points)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
||||
foreach (var (x, y) in points.Skip(1))
|
||||
program.Codes.Add(new LinearMove(x, y));
|
||||
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
||||
return PartGeometrySnapshot.FromProgram(program);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public static class JobBuilder
|
||||
{
|
||||
public static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) =>
|
||||
new(parts, stock, options);
|
||||
|
||||
public static NestJobPart Part(string id, Program program, int quantity,
|
||||
RotationPolicy? rotation = null, int priority = 0) =>
|
||||
new(id, PartGeometrySnapshot.FromProgram(program), quantity, priority, rotation);
|
||||
|
||||
/// <param name="width">Y extent.</param>
|
||||
/// <param name="length">X extent.</param>
|
||||
public static NestPlateStock Stock(string id, double width, double length, double spacing = 0,
|
||||
Spacing edge = default, int quadrant = 1, int? quantity = null) =>
|
||||
new(id, new Size(width, length), quantity, spacing, edge, quadrant);
|
||||
|
||||
public static NestJobPart Rectangle(string id, double w, double h, int count,
|
||||
RotationPolicy? rotation = null, double x = 0, double y = 0) =>
|
||||
Part(id, Shapes.Polyline((x, y), (x + w, y), (x + w, y + h), (x, y + h)),
|
||||
count, rotation ?? RotationPolicy.Fixed(0));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using Xunit;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public static class LayoutAssert
|
||||
{
|
||||
public static void Valid(NestJob job, NestJobResult result)
|
||||
{
|
||||
var violations = NestLayoutCheck.Violations(job, result);
|
||||
Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations));
|
||||
Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex));
|
||||
foreach (var f in result.Fulfillment)
|
||||
Assert.Equal(f.Requested, f.Placed + f.Unplaced);
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
var s = sheet.Stock;
|
||||
var work = s.WorkArea;
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
var part = job.Parts.Single(p => p.Id == pose.PartId);
|
||||
Assert.True(part.Rotation.Allows(pose.Rotation));
|
||||
var geometry = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry))
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToArray();
|
||||
foreach (var entity in geometry) { entity.Rotate(pose.Rotation); entity.Offset(pose.X, pose.Y); }
|
||||
var b = (L: geometry.Min(e => e.Left), B: geometry.Min(e => e.Bottom),
|
||||
R: geometry.Max(e => e.Right), T: geometry.Max(e => e.Top));
|
||||
Assert.True(b.L >= work.Left - 1e-7 && b.B >= work.Bottom - 1e-7
|
||||
&& b.R <= work.Right + 1e-7 && b.T <= work.Top + 1e-7);
|
||||
}
|
||||
}
|
||||
foreach (var part in job.Parts)
|
||||
{
|
||||
var placed = result.Plates.SelectMany(s => s.Placements).Where(p => p.PartId == part.Id).ToArray();
|
||||
Assert.Equal(Enumerable.Range(0, placed.Length), placed.Select(p => p.InstanceIndex).Order());
|
||||
var fulfillment = result.Fulfillment.Single(f => f.PartId == part.Id);
|
||||
Assert.Equal(placed.Length, fulfillment.Placed);
|
||||
Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced);
|
||||
}
|
||||
foreach (var usage in result.StockUsage)
|
||||
{
|
||||
var stock = job.Plates.Single(s => s.Id == usage.StockId);
|
||||
Assert.Equal(result.Plates.Count(s => s.StockId == stock.Id), usage.Used);
|
||||
Assert.Equal(stock.Quantity - usage.Used, usage.Remaining);
|
||||
Assert.True(usage.Remaining is null or >= 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
/// <summary>
|
||||
/// Starter acceptance tests. Every layout is checked by the shared NestLayoutCheck the benchmark
|
||||
/// scores with, so a passing test means the benchmark will accept the layout. They fail until
|
||||
/// Solve() is implemented; add engine-specific tests alongside them.
|
||||
/// </summary>
|
||||
public class RectanglesNestingEngineTests
|
||||
{
|
||||
[Fact]
|
||||
public void HasPublicParameterlessConstructorForPluginDiscovery()
|
||||
{
|
||||
var engine = Activator.CreateInstance(typeof(RectanglesNestingEngine));
|
||||
Assert.IsAssignableFrom<INestingEngine>(engine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RectanglesFitOnOneSheetWithSpacing()
|
||||
{
|
||||
var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
Assert.Equal(12, result.Plates[0].Placements.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant)
|
||||
{
|
||||
var job = Job(
|
||||
new[]
|
||||
{
|
||||
Part("disc", Disc(3), 10),
|
||||
Part("ell", LShape(12, 8, 4), 10),
|
||||
Part("tri", Triangle(9, 6), 10),
|
||||
},
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) }
|
||||
);
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverflowSpillsOntoAdditionalSheets()
|
||||
{
|
||||
var job = Job(new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.True(result.Plates.Count > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartTooBigForAnySheetIsReportedUnplaced()
|
||||
{
|
||||
var job = Job(
|
||||
new[] { Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(5, 5), 4) },
|
||||
new[] { Stock("sheet", 20, 20, spacing: 0.25) }
|
||||
);
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge");
|
||||
Assert.Equal(1, huge.Unplaced);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactFitGridPacksAtExactlyThePartSpacing()
|
||||
{
|
||||
// 4 x 3 boxes of 10 x 5 at 0.5 spacing need exactly 41.5 x 16.
|
||||
var job = Job(new[] { Part("r", Rectangle(10, 5), 12) },
|
||||
new[] { Stock("s", 16, 41.5, spacing: 0.5, quantity: 1) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(12, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ArcExtremePartsStayClearAtTheSpacing()
|
||||
{
|
||||
// Discs and obrounds have arcs, not vertices, at their box edges: the validator's
|
||||
// circumscribed flattening would read box-touching copies as closer than the spacing.
|
||||
var job = Job(new[] { Part("disc", Disc(2), 20), Part("ob", Obround(8, 3), 12) },
|
||||
new[] { Stock("s", 30, 40, spacing: 0.25) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedSizesFillOneSheetThatShelfPackingWouldSplit()
|
||||
{
|
||||
// Area check: 2*(24x20) + 4*(12x10) + 8*(6x5) = 960 + 480 + 240 = 1680 of 48 x 40 = 1920.
|
||||
// A maximal-rectangles packing fits all of it on one sheet with zero spacing.
|
||||
var job = Job(new[]
|
||||
{
|
||||
Rectangle("big", 24, 20, 2, RotationPolicy.Automatic),
|
||||
Rectangle("mid", 12, 10, 4, RotationPolicy.Automatic),
|
||||
Rectangle("small", 6, 5, 8, RotationPolicy.Automatic),
|
||||
},
|
||||
new[] { Stock("s", 40, 48) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Single(result.Plates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotatedInputIsNestedAtItsMinimumBoundingRectangle()
|
||||
{
|
||||
// A 10 x 4 rectangle drawn at 30 degrees: only its squared-up box fits 4 per 20.5 x 8.5 sheet.
|
||||
var c = System.Math.Cos(System.Math.PI / 6);
|
||||
var s = System.Math.Sin(System.Math.PI / 6);
|
||||
(double, double) R(double x, double y) => (x * c - y * s + 5, x * s + y * c + 5);
|
||||
var tilted = Polyline(R(0, 0), R(10, 0), R(10, 4), R(0, 4));
|
||||
var job = Job(new[] { Part("tilted", tilted, 4, RotationPolicy.Automatic) },
|
||||
new[] { Stock("s", 8.5, 20.5, spacing: 0.5, quantity: 1) });
|
||||
|
||||
var result = new RectanglesNestingEngine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RectanglesContractTests : EngineContractTests<RectanglesNestingEngine> { }
|
||||
@@ -0,0 +1,56 @@
|
||||
using OpenNest.CNC;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
public static class Shapes
|
||||
{
|
||||
public static Program Polyline(params (double X, double Y)[] points)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(points[0].X, points[0].Y));
|
||||
foreach (var (x, y) in points.Skip(1))
|
||||
program.Codes.Add(new LinearMove(x, y));
|
||||
program.Codes.Add(new LinearMove(points[0].X, points[0].Y));
|
||||
return program;
|
||||
}
|
||||
|
||||
public static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h));
|
||||
|
||||
public static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h));
|
||||
|
||||
public static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h));
|
||||
|
||||
public static Program Disc(double r)
|
||||
{
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(r, 0));
|
||||
program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW));
|
||||
program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
|
||||
/// <summary>Stadium: two semicircular ends joined by straight sides, offset from the origin.</summary>
|
||||
public static Program Obround(double length, double width)
|
||||
{
|
||||
var r = width / 2;
|
||||
var program = new Program();
|
||||
program.Codes.Add(new RapidMove(1 + r, 1));
|
||||
program.Codes.Add(new LinearMove(1 + length - r, 1));
|
||||
program.Codes.Add(new ArcMove(1 + length - r, 1 + width, 1 + length - r, 1 + r, RotationType.CCW));
|
||||
program.Codes.Add(new LinearMove(1 + r, 1 + width));
|
||||
program.Codes.Add(new ArcMove(1 + r, 1, 1 + r, 1 + r, RotationType.CCW));
|
||||
return program;
|
||||
}
|
||||
public static Program NotchedPartWithEtch()
|
||||
{
|
||||
var p = new Program();
|
||||
p.MoveTo(0, 0); p.LineTo(10, 0); p.LineTo(10, 4); p.LineTo(8, 4); p.LineTo(8, 6);
|
||||
p.LineTo(10, 6); p.LineTo(10, 10); p.LineTo(0, 10); p.LineTo(0, 0);
|
||||
p.MoveTo(7.5, 5);
|
||||
p.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe });
|
||||
return p;
|
||||
}
|
||||
|
||||
public static Program Ring(double outerDiameter, double innerDiameter) =>
|
||||
new OpenNest.Shapes.RingShape { OuterDiameter = outerDiameter, InnerDiameter = innerDiameter }.GetDrawing().Program;
|
||||
}
|
||||
Reference in New Issue
Block a user