Files
OpenNest/OpenNest.Engine.Tests/Jobs/IrregularStockTests.cs
T
ajandClaude Fable 5.1 101d7b5a50 fix(desktop): refuse fills on an outlined plate before the service
PlateFillService throws for a plate with a sheet outline, but the desktop
swallowed it: Fill (Ctrl+F) caught every exception and showed nothing, and
Fill Area's async void either completed silently or let it escape.

PlateFillService.Refusal(plate) names the reason, and every caller checks
it first: Fill and Fill Area put it in the status bar and add nothing,
Pattern Tile offers a new plate only, the MCP fill/pack tools return it as
an Error result, and the console exits 2 with it unless --autonest is given.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-29 00:23:10 -04:00

248 lines
11 KiB
C#

using Clipper2Lib;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.NestingEngines.Default;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Irregular stock: a <see cref="SheetOutline"/> on the stock reaches the Irregular packer as its
/// boundary, every placement stays inside the outline and off its obstacles, the layout check and
/// the runner's validation reject one that does not, and engines that only fill rectangles place
/// nothing on it.
/// </summary>
public class IrregularStockTests
{
private const double Spacing = 0.5;
/// <summary>A 96 x 48 sheet (X by Y) missing its upper-right 48 x 24 corner.</summary>
private static SheetOutline Ell() => new(new[]
{
new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(48, 24), new Vector(48, 48), new Vector(0, 48),
});
/// <summary>A 96 x 48 sheet whose upper-right corner is cut off by a diagonal from (72, 48) to (96, 24).</summary>
private static SheetOutline Chamfered() => new(new[]
{
new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(72, 48), new Vector(0, 48),
});
/// <summary>A whole 96 x 48 sheet with a 20 x 20 burned patch in the middle.</summary>
private static SheetOutline Burned() => new(
new[] { new Vector(0, 0), new Vector(96, 0), new Vector(96, 48), new Vector(0, 48) },
new[] { new[] { new Vector(38, 14), new Vector(58, 14), new Vector(58, 34), new Vector(38, 34) } });
private static NestPlateStock Stock(SheetOutline outline, int quadrant = 1, Spacing edge = default, int? quantity = 1) =>
new("drop", outline.Size, quantity, Spacing, edge, quadrant, null, null, outline);
private static NestJobPart Square(string id, double side, int quantity) =>
new(id, PartGeometrySnapshot.FromProgram(TestShapes.Rectangle(side, side)), quantity);
private static void AssertInsideOutline(NestJob job, NestJobResult result)
{
Assert.Empty(NestLayoutCheck.Violations(job, result));
foreach (var sheet in result.Plates)
{
var usable = sheet.Stock.UsableOutline();
foreach (var pose in sheet.Placements)
{
var geometry = JobPartGeometry.Read(job.Parts.Single(p => p.Id == pose.PartId).Geometry);
var perimeter = (Shape)geometry.Perimeter.Clone();
perimeter.Rotate(pose.Rotation);
perimeter.Offset(pose.X, pose.Y);
var path = ClipperBridge.ToPath(ClipperBridge.Flatten(perimeter, 0.001, circumscribe: true), positive: true);
var outside = Clipper.Difference(new PathsD { path }, usable, FillRule.NonZero, 4);
Assert.True(outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0,
$"{pose.PartId} at ({pose.X:F2},{pose.Y:F2}) leaves the usable outline");
}
}
}
[Fact]
public void Stock_CarriesTheOutlineAndItsArea()
{
var stock = Stock(Ell(), quadrant: 3, edge: new Spacing(1, 0.5, 0.25, 0.75));
Assert.Equal(96 * 48 - 48 * 24, stock.Area, 6);
Assert.Equal(1, stock.OutlineInset);
Assert.Equal(new Vector(-96, -48), stock.PlacementOrigin);
var usable = stock.UsableOutline();
var bounds = Clipper.GetBounds(usable);
Assert.Equal(-95, bounds.left, 6);
Assert.Equal(-47, bounds.top, 6);
Assert.Equal(-1, bounds.right, 6);
Assert.Equal(-1, bounds.bottom, 6);
Assert.Throws<ArgumentException>(() =>
new NestPlateStock("bad", new Size(48, 100), 1, Spacing, default, 1, null, null, Ell()));
}
[Fact]
public void Mapper_CarriesTheOutlineBothWays()
{
var plate = new Plate { Outline = Ell(), Quadrant = 2 };
var stock = DrawingJobMapper.FromPlate("s", plate, null);
Assert.Equal(Ell(), stock.Outline);
var rebuilt = DrawingJobMapper.CreatePlate(stock);
Assert.Equal(Ell(), rebuilt.Outline);
Assert.Equal(plate.Size, rebuilt.Size);
Assert.Equal(2, rebuilt.Quadrant);
}
[Theory]
[InlineData(1)]
[InlineData(3)]
public void Irregular_FillsAnEllSheetWithoutEnteringTheMissingCorner(int quadrant)
{
// Six 20 x 20 squares fit: four along the 96 x 24 arm and two more on the 48 x 48 column.
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell(), quadrant)]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(6, result.Plates.Sum(p => p.Placements.Count));
AssertInsideOutline(job, result);
}
[Fact]
public void Irregular_KeepsTheLargestEdgeSpacingFromADiagonalEdge()
{
var job = new NestJob([Square("sq", 10, 40)], [Stock(Chamfered(), edge: new Spacing(0.25, 1.5, 0.25, 0.25))]);
var result = new IrregularNestingEngine().Solve(job);
// The chamfer removes a 24 x 24 triangle (288) from 4608; at 1.5 inset about 36 squares fit.
Assert.InRange(result.Plates.Sum(p => p.Placements.Count), 30, 40);
AssertInsideOutline(job, result);
}
[Fact]
public void Irregular_NestsAroundAnObstacle()
{
var job = new NestJob([Square("sq", 15, 12)], [Stock(Burned())]);
var result = new IrregularNestingEngine().Solve(job);
Assert.True(result.Plates.Sum(p => p.Placements.Count) >= 10);
AssertInsideOutline(job, result);
}
[Fact]
public void Default_UsesIrregularForAnOutlinedSheetAndRectanglesPlacesNothing()
{
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell())]);
var rectangles = new RectanglesNestingEngine().Solve(job);
Assert.Equal(0, rectangles.Plates.Sum(p => p.Placements.Count));
Assert.Equal(NestJobStopReason.NoPlacementFound, rectangles.StopReason);
var fill = new FixedStrategyNestingEngine("Fill").Solve(job);
Assert.Equal(0, fill.Plates.Sum(p => p.Placements.Count));
var chosen = new DefaultNestingEngine().Solve(job);
Assert.Equal(6, chosen.Plates.Sum(p => p.Placements.Count));
AssertInsideOutline(job, chosen);
}
[Fact]
public void Default_PrefersARectangleWhenAnOutlinedSheetIsAlsoOffered()
{
var rectangle = new NestPlateStock("sheet", new Size(48, 96), 1, Spacing);
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell()), rectangle]);
var result = new DefaultNestingEngine().Solve(job);
Assert.Equal(6, result.Plates.Sum(p => p.Placements.Count));
Assert.Empty(NestLayoutCheck.Violations(job, result));
}
[Fact]
public void LayoutCheck_RejectsAPartInTheMissingCornerOrOverAnObstacle()
{
var corner = new NestJob([Square("sq", 20, 2)], [Stock(Ell())]);
var inCorner = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, corner.Plates[0], [new NestJobPlacement("sq", 0, 1, 1, 0), new NestJobPlacement("sq", 1, 60, 26, 0)])],
[new PartFulfillment("sq", 2, 2, 0)], [new StockUsage("drop", 1, 0)]);
var violation = Assert.Single(NestLayoutCheck.Violations(corner, inCorner));
Assert.Contains("(60.00,26.00)", violation);
Assert.Contains("outline", violation);
var burned = new NestJob([Square("sq", 20, 1)], [Stock(Burned())]);
var onPatch = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, burned.Plates[0], [new NestJobPlacement("sq", 0, 30, 20, 0)])],
[new PartFulfillment("sq", 1, 1, 0)], [new StockUsage("drop", 1, 0)]);
Assert.Contains(NestLayoutCheck.Violations(burned, onPatch), v => v.Contains("obstacle"));
// A placement inside the arm, exactly at the edge, passes.
var fine = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, corner.Plates[0], [new NestJobPlacement("sq", 0, 0, 0, 0), new NestJobPlacement("sq", 1, 76, 4, 0)])],
[new PartFulfillment("sq", 2, 2, 0)], [new StockUsage("drop", 1, 0)]);
Assert.Empty(NestLayoutCheck.Violations(corner, fine));
}
[Fact]
public void Runner_RefusesACandidateOutsideTheOutline()
{
var job = new NestJob([Square("sq", 20, 1)], [Stock(Ell())]);
var candidate = new PlateCandidate([new NestJobPlacement("sq", 0, 60, 26, 0)]);
var parts = job.Parts.ToDictionary(p => p.Id);
var remaining = new Dictionary<string, int> { ["sq"] = 1 };
var exception = Assert.Throws<InvalidOperationException>(() =>
NestJobValidator.ValidateCandidate(candidate, job.Plates[0], remaining, parts));
Assert.Contains("usable stock area", exception.Message);
NestJobValidator.ValidateCandidate(new PlateCandidate([new NestJobPlacement("sq", 0, 76, 4, 0)]),
job.Plates[0], remaining, parts);
}
[Fact]
public void Pipeline_CommitsTheOutlineToThePlate()
{
var nest = new Nest("drop");
var drawing = new Drawing("sq", TestShapes.Rectangle(20, 20));
drawing.Quantity.Required = 6;
nest.Drawings.Add(drawing);
var template = new Plate { Outline = Ell(), PartSpacing = Spacing };
nest.Plates.Add(template);
var request = new NestPipelineRequest("Irregular",
[new NestItem { Drawing = drawing, Quantity = 6 }], NestStockBuilder.SinglePlate(template), new NestJobOptions());
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("\n", result.Violations));
var applied = NestPipelineCommit.ApplyToPlates(result, () => template);
var plate = Assert.Single(applied);
Assert.Equal(Ell(), plate.Outline);
Assert.Equal(6, plate.Parts.Count);
}
[Fact]
public void FillService_RefusesAnOutlinedPlate()
{
var plate = new Plate { Outline = Ell() };
var item = new NestItem { Drawing = new Drawing("sq", TestShapes.Rectangle(20, 20)), Quantity = 1 };
var exception = Assert.Throws<InvalidOperationException>(() =>
PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), null, default));
Assert.Contains("rectangular", exception.Message);
// Callers check the refusal first and show it instead of catching the exception.
Assert.Equal(exception.Message, PlateFillService.Refusal(plate));
Assert.Null(PlateFillService.Refusal(new Plate(48, 96)));
Assert.Null(PlateFillService.Refusal(null));
}
[Fact]
public void RepeatedPlates_DoNotGroupAnOutlinedSheetWithItsRectangle()
{
var outlined = Stock(Ell(), quantity: null);
var plain = new NestPlateStock("drop", new Size(48, 96), null, Spacing);
Assert.False(RepeatedPlateGrouping.SameStock(outlined, plain));
Assert.True(RepeatedPlateGrouping.SameStock(outlined, Stock(Ell(), quantity: null)));
}
}