Files
OpenNest/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs
T
ajandClaude Opus 5.5 44372d129e feat(ui): rename the Fill engine to Lattice and describe engines
"Fill" said nothing about what the engine does, since every engine fills.
It is now Lattice, and Fill stays an alias, so saved selections, scripts
and API requests keep working. Engine descriptions are rewritten in plain
language and shown beside each name in the desktop engine lists; the main
toolbar's tooltip also says when Fill Area falls back to Lattice.

Docs also cover the leftover placement from the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:17:16 -05:00

410 lines
15 KiB
C#

using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.Jobs.Placement.Fillers;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Direct filler-backed coverage for the built-in plate nesters (Default/Strip/remnant). The
/// runner's placement validator enforces geometric safety on every committed candidate, so these
/// tests assert fulfillment, status, and the identity/rotation safety boundaries; exact committed
/// layouts are pinned separately by <see cref="GoldenLayoutTests"/>.
/// </summary>
public class PlateNesterParityTests
{
private const double Tolerance = 1e-6;
private static readonly Size PlateSize = new(30, 50);
private static readonly Spacing Edge = new(1, 1, 1, 1);
private static NestJob Job(
IReadOnlyList<NestJobPart> parts,
int? stockQuantity = 3,
string strategy = "Fill"
)
{
var stock = new NestPlateStock("stock", PlateSize, stockQuantity, 1, Edge);
return new NestJob(parts, new[] { stock }, new NestJobOptions(strategy));
}
private static NestJobResult Solve(IPlateNester nester, NestJob job) =>
new NestJobRunner(_ => nester).Solve(job);
private static Dictionary<string, PartFulfillment> ByPart(NestJobResult result) =>
result.Fulfillment.ToDictionary(f => f.PartId, StringComparer.Ordinal);
private static bool AnglesEqual(double left, double right)
{
var delta = (left - right) % (System.Math.PI * 2);
return System.Math.Abs(delta) <= Tolerance
|| System.Math.Abs(System.Math.Abs(delta) - System.Math.PI * 2) <= Tolerance;
}
[Fact]
public void DefaultDirectFiller_Rectangles_FulfilledAndValid()
{
var parts = new[]
{
new NestJobPart(
"a",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)),
4
),
new NestJobPart(
"b",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)),
3
),
};
var result = Solve(PlateNesterFactory.Create("Fill"), Job(parts));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(4, ByPart(result)["a"].Placed);
Assert.Equal(3, ByPart(result)["b"].Placed);
var usage = Assert.Single(result.StockUsage);
Assert.Equal(1, usage.Used);
Assert.Equal(7, result.Plates.SelectMany(p => p.Placements).Count());
}
[Fact]
public void StripDirectFiller_Rectangles_FulfilledAndValid()
{
var parts = new[]
{
new NestJobPart(
"a",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)),
4
),
new NestJobPart(
"b",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)),
3
),
};
var result = Solve(PlateNesterFactory.Create("Strip"), Job(parts, strategy: "Strip"));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(4, ByPart(result)["a"].Placed);
Assert.Equal(3, ByPart(result)["b"].Placed);
Assert.Equal(7, result.Plates.SelectMany(p => p.Placements).Count());
}
[Fact]
public void Builtins_AreResolvedByProductionFactory()
{
Assert.IsType<PlateNester>(PlateNesterFactory.Create("Fill"));
Assert.IsType<PlateNester>(PlateNesterFactory.Create("Strip"));
Assert.IsType<PlateNester>(PlateNesterFactory.Create("Vertical Remnant"));
Assert.IsType<PlateNester>(PlateNesterFactory.Create("Horizontal Remnant"));
Assert.Throws<NotSupportedException>(() => PlateNesterFactory.Create("not registered"));
}
[Theory]
[InlineData("Fill", "Lattice")]
[InlineData("Default", "Lattice")]
[InlineData("Strip", "Strip")]
[InlineData("Vertical Remnant", "Vertical Remnant")]
[InlineData("Horizontal Remnant", "Horizontal Remnant")]
public void StrategyTable_MapsEachNameToItsFiller(string name, string canonical)
{
var entry = PlacementStrategies.Find(name, StringComparison.Ordinal);
Assert.NotNull(entry);
Assert.Equal(canonical, entry.Name);
var (policy, layout) = canonical switch
{
"Lattice" => (PlateFillPolicy.Fill, PlateLayout.FillThenPack),
"Strip" => (PlateFillPolicy.Fill, PlateLayout.Strips),
"Vertical Remnant" => (PlateFillPolicy.VerticalRemnant, PlateLayout.FillThenPack),
_ => (PlateFillPolicy.HorizontalRemnant, PlateLayout.FillThenPack),
};
Assert.Same(policy, entry.Policy);
Assert.Equal(layout, entry.Layout);
}
[Fact]
public void StrategyTable_ListsTheBuiltInStrategiesInOrder() =>
Assert.Equal(
PlateFillService.BuiltInStrategies,
PlacementStrategies.Entries.Select(entry => entry.Name)
);
[Theory]
[InlineData("fill")]
[InlineData("default")]
[InlineData("STRIP")]
[InlineData("vertical remnant")]
[InlineData("")]
public void Factory_MatchesNamesCaseSensitively(string strategy)
{
var error = Assert.Throws<NotSupportedException>(() => PlateNesterFactory.Create(strategy));
Assert.Equal($"Unknown placement strategy: {strategy}.", error.Message);
}
[Fact]
public void AsymmetricPart_ValidAndFulfilled()
{
// L-shape: 6x4 outer with a corner notch removed (single closed contour, asymmetric).
var lshape = new Program();
lshape.MoveTo(0, 0);
lshape.LineTo(6, 0);
lshape.LineTo(6, 4);
lshape.LineTo(3, 4);
lshape.LineTo(3, 2);
lshape.LineTo(0, 2);
lshape.LineTo(0, 0);
var parts = new[]
{
new NestJobPart("l", PartGeometrySnapshot.FromProgram(lshape), 3),
new NestJobPart(
"sq",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)),
2
),
};
var result = Solve(PlateNesterFactory.Create("Fill"), Job(parts));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(3, ByPart(result)["l"].Placed);
Assert.Equal(2, ByPart(result)["sq"].Placed);
Assert.Equal(5, result.Plates.SelectMany(p => p.Placements).Count());
}
[Fact]
public void HoleAndArcParts_ValidAndFulfilled()
{
// 6x6 rectangle with a 2x2 inner hole (rapid contour), plus a D-shape with a semicircular arc.
var holed = new Program();
holed.MoveTo(0, 0);
holed.LineTo(6, 0);
holed.LineTo(6, 6);
holed.LineTo(0, 6);
holed.LineTo(0, 0);
holed.MoveTo(2, 2);
holed.LineTo(4, 2);
holed.LineTo(4, 4);
holed.LineTo(2, 4);
holed.LineTo(2, 2);
var arc = new Program();
arc.MoveTo(0, 0);
arc.LineTo(3, 0);
arc.ArcTo(3, 5, 3, 2.5, RotationType.CCW);
arc.LineTo(0, 5);
arc.LineTo(0, 0);
var parts = new[]
{
new NestJobPart("holed", PartGeometrySnapshot.FromProgram(holed), 2),
new NestJobPart("arc", PartGeometrySnapshot.FromProgram(arc), 2),
};
var result = Solve(PlateNesterFactory.Create("Fill"), Job(parts));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, ByPart(result)["holed"].Placed);
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<double>)anglesMethod!.Invoke(null, new object[] { policy })!
).ToList();
Assert.Equal(new[] { 0.0, System.Math.PI }, angles);
}
[Fact]
public void FixedRotation_Respected()
{
var part = new NestJobPart(
"fixed",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)),
2,
rotation: RotationPolicy.Fixed(0)
);
var result = Solve(PlateNesterFactory.Create("Fill"), Job(new[] { part }));
Assert.Equal(NestJobStatus.Complete, result.Status);
var placements = result.Plates.SelectMany(p => p.Placements).ToList();
Assert.Equal(2, placements.Count);
foreach (var placement in placements)
Assert.True(
AnglesEqual(placement.Rotation, 0),
$"fixed rotation violated: {placement.Rotation}"
);
}
[Fact]
public void DefaultRestrictedRotation_NeverTouchesFiller()
{
// Safety rule, not layout: any non-automatic rotation must bypass the Default fill
// pipeline entirely — its Pairs/RectBestFit strategies rotate freely and would propose
// forbidden poses. A throwing filler factory proves the filler is never constructed, and
// completion at the locked angle proves OrderedPlateNester handled the request.
var part = new NestJobPart(
"fixed",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)),
2,
rotation: RotationPolicy.Fixed(0)
);
var nester = new PlateNester(_ =>
throw new InvalidOperationException("filler must not run for restricted rotation")
);
var result = Solve(nester, Job(new[] { part }));
Assert.Equal(NestJobStatus.Complete, result.Status);
var placements = result.Plates.SelectMany(p => p.Placements).ToList();
Assert.Equal(2, placements.Count);
Assert.All(placements, p => Assert.True(AnglesEqual(p.Rotation, 0)));
}
[Fact]
public void RemnantRestrictedRotation_NeverTouchesFiller()
{
// Same safety rule for the remnant nester, whose fillers inherit the Fill pipeline's
// automatic-rotation limitation. The throwing factory proves the filler is never
// constructed; completion at the locked angle proves OrderedPlateNester handled it.
var part = new NestJobPart(
"fixed",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)),
2,
rotation: RotationPolicy.Fixed(0)
);
var nester = new PlateNester(_ =>
throw new InvalidOperationException("filler must not run for restricted rotation")
);
var result = Solve(nester, Job(new[] { part }, strategy: "Vertical Remnant"));
Assert.Equal(NestJobStatus.Complete, result.Status);
var placements = result.Plates.SelectMany(p => p.Placements).ToList();
Assert.Equal(2, placements.Count);
Assert.All(placements, p => Assert.True(AnglesEqual(p.Rotation, 0)));
}
[Fact]
public void StripRestrictedRotation_NeverTouchesFiller()
{
// Same safety rule for Strip, whose shrink fills use two classification angles and
// ignore the requirement's rotation bounds. The throwing factory proves the filler is
// never constructed; completion at the locked angle proves OrderedPlateNester handled it.
var part = new NestJobPart(
"fixed",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)),
2,
rotation: RotationPolicy.Fixed(0)
);
var nester = new PlateNester(_ =>
throw new InvalidOperationException("filler must not run for restricted rotation")
);
var result = Solve(nester, Job(new[] { part }, strategy: "Strip"));
Assert.Equal(NestJobStatus.Complete, result.Status);
var placements = result.Plates.SelectMany(p => p.Placements).ToList();
Assert.Equal(2, placements.Count);
Assert.All(placements, p => Assert.True(AnglesEqual(p.Rotation, 0)));
}
[Fact]
public void RepeatedNames_KeepIndependentIdentity()
{
// Two distinct requirements sharing identical geometry (and, via the mapper, name) but different IDs.
var program = TestDrawingFactory.Rectangle(6, 4);
var parts = new[]
{
new NestJobPart("first", PartGeometrySnapshot.FromProgram(program), 2),
new NestJobPart("second", PartGeometrySnapshot.FromProgram(program), 1),
};
var result = Solve(PlateNesterFactory.Create("Fill"), Job(parts));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, ByPart(result)["first"].Placed);
Assert.Equal(1, ByPart(result)["second"].Placed);
var ids = result.Plates.SelectMany(p => p.Placements).Select(p => p.PartId);
Assert.Equal(2, ids.Count(id => id == "first"));
Assert.Equal(1, ids.Count(id => id == "second"));
}
[Fact]
public void OffsetGeometry_ValidAndFulfilled()
{
// Part contour starting at a nonzero origin (offset geometry).
var program = new Program();
program.MoveTo(12, 7);
program.LineTo(18, 7);
program.LineTo(18, 11);
program.LineTo(12, 11);
program.LineTo(12, 7);
var part = new NestJobPart("offset", PartGeometrySnapshot.FromProgram(program), 2);
var result = Solve(PlateNesterFactory.Create("Fill"), Job(new[] { part }));
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, ByPart(result)["offset"].Placed);
// The runner's validator guarantees containment and non-overlap for every committed placement.
}
[Fact]
public void RunScopedCache_DrawingReusedAcrossTrials()
{
// 14x9 parts on 30x20: one sheet holds fewer than five, so the runner runs multiple candidate
// trials through the same nester instance. The run-scoped drawing cache must keep producing
// valid, correctly-attributed placements across trials.
var part = new NestJobPart(
"p",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(14, 9)),
5
);
var stock = new NestPlateStock("stock", new Size(30, 20), 3);
var nester = PlateNesterFactory.Create("Fill");
var result = new NestJobRunner(_ => nester).Solve(
new NestJob(new[] { part }, new[] { stock })
);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "p").Placed);
Assert.Equal(2, result.Plates.Count);
var usage = result.StockUsage.Single();
Assert.Equal(2, usage.Used);
Assert.Equal(1, usage.Remaining);
}
[Fact]
public void DirectRemnantStrategies_FulfillThroughFactory()
{
// Remnant strategies resolve to the direct remnant nester and still fulfill after the
// legacy adapter was deleted.
var part = new NestJobPart(
"p",
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)),
2
);
foreach (var strategy in new[] { "Vertical Remnant", "Horizontal Remnant" })
{
var result = Solve(
PlateNesterFactory.Create(strategy),
Job(new[] { part }, strategy: strategy)
);
Assert.Equal(NestJobStatus.Complete, result.Status);
Assert.Equal(2, result.Fulfillment.Single(f => f.PartId == "p").Placed);
}
}
}