mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-05 13:41:37 -05:00
Whole-job fills treated clamps as occupied space, so a clamped edge cost a row: 8.25in circles stopped at 96 on a sheet the plate fill packs with 102. When one drawing still has unmet demand, also fill the sheet as if the clamps were absent, drop the parts on them and keep the better layout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
441 lines
15 KiB
C#
441 lines
15 KiB
C#
using System.Threading;
|
|
using OpenNest.Engine.Fill;
|
|
using OpenNest.Engine.Jobs.Placement.Fillers;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Engine.Tests.Jobs;
|
|
|
|
public class PlateFillerContractTests
|
|
{
|
|
[Theory]
|
|
[InlineData("Fill")]
|
|
[InlineData("Vertical Remnant")]
|
|
[InlineData("Horizontal Remnant")]
|
|
public void StandardPlateFiller_Fill_ReturnsPartsBoundToInputDrawing(string strategy)
|
|
{
|
|
var plate = new Plate(new Size(30, 50));
|
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4));
|
|
var filler = CreateFiller(strategy, plate);
|
|
|
|
var parts = filler.Fill(
|
|
new NestItem { Drawing = drawing, Quantity = 4 },
|
|
plate.WorkArea(),
|
|
null,
|
|
CancellationToken.None
|
|
);
|
|
|
|
Assert.NotEmpty(parts);
|
|
Assert.All(parts, part => Assert.Same(drawing, part.BaseDrawing));
|
|
Assert.NotEmpty(filler.PhaseResults);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlateFiller_UsesOverriddenAngleSelection()
|
|
{
|
|
var plate = new Plate(new Size(30, 50));
|
|
var filler = new AngleProbePlateFiller(plate);
|
|
|
|
var parts = filler.Fill(
|
|
new NestItem
|
|
{
|
|
Drawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4)),
|
|
Quantity = 4,
|
|
},
|
|
plate.WorkArea(),
|
|
null,
|
|
CancellationToken.None
|
|
);
|
|
|
|
Assert.NotEmpty(parts);
|
|
Assert.True(filler.BuildAnglesCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlateFiller_Nest_UsesOverriddenFillAndPackArea()
|
|
{
|
|
var plate = new Plate(new Size(100, 100));
|
|
var fillDrawing = new Drawing("fill", TestDrawingFactory.Rectangle(10, 10));
|
|
var packDrawing = new Drawing("pack", TestDrawingFactory.Rectangle(10, 10));
|
|
var filler = new FillAndPackProbePlateFiller(plate, fillDrawing, packDrawing);
|
|
|
|
var parts = filler.Nest(
|
|
new List<NestItem>
|
|
{
|
|
new() { Drawing = fillDrawing, Quantity = 10 },
|
|
new() { Drawing = packDrawing, Quantity = 1 },
|
|
},
|
|
null,
|
|
CancellationToken.None
|
|
);
|
|
|
|
Assert.Equal(1, filler.FillCalls);
|
|
Assert.Equal(1, filler.PackAreaCalls);
|
|
Assert.Equal(11, parts.Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void StripLayout_Nest_ReturnsPlacedPartsAndDeductsInput()
|
|
{
|
|
var plate = new Plate(new Size(30, 50));
|
|
var items = new List<NestItem>
|
|
{
|
|
new()
|
|
{
|
|
Drawing = new Drawing("rect-a", TestDrawingFactory.Rectangle(6, 4)),
|
|
Quantity = 5,
|
|
},
|
|
new()
|
|
{
|
|
Drawing = new Drawing("rect-b", TestDrawingFactory.Rectangle(4, 3)),
|
|
Quantity = 4,
|
|
},
|
|
new()
|
|
{
|
|
Drawing = new Drawing("rect-c", TestDrawingFactory.Rectangle(2, 7)),
|
|
Quantity = 3,
|
|
},
|
|
};
|
|
var filler = new PlateFiller(plate, PlateFillPolicy.Fill, PlateLayout.Strips) { PlateNumber = 7 };
|
|
|
|
var parts = filler.Nest(items, null, CancellationToken.None);
|
|
|
|
Assert.NotEmpty(parts);
|
|
Assert.All(parts, part => Assert.Contains(items, item => ReferenceEquals(item.Drawing, part.BaseDrawing)));
|
|
Assert.All(items, item => Assert.InRange(item.Quantity, 0, 5));
|
|
}
|
|
|
|
[Fact]
|
|
public void StripLayout_Nest_UsesOverriddenPackArea()
|
|
{
|
|
var plate = new Plate(new Size(100, 100));
|
|
var drawing = new Drawing("pack", TestDrawingFactory.Rectangle(10, 10));
|
|
var filler = new PackProbeStripFiller(plate, drawing);
|
|
|
|
var parts = filler.Nest(
|
|
new List<NestItem> { new() { Drawing = drawing, Quantity = 1 } },
|
|
null,
|
|
CancellationToken.None
|
|
);
|
|
|
|
Assert.Equal(1, filler.PackAreaCalls);
|
|
Assert.Single(parts);
|
|
}
|
|
|
|
[Fact]
|
|
public void NestProgressReporter_Report_ClonesPartsAndPreservesReportFields()
|
|
{
|
|
var source = new Part(
|
|
new Drawing("part", TestDrawingFactory.Rectangle(10, 20)),
|
|
new Vector(3, 5)
|
|
);
|
|
source.Rotate(0.5);
|
|
var progress = new CapturingProgress();
|
|
var workArea = new Box(1, 2, 30, 40);
|
|
|
|
NestProgressReporter.Report(
|
|
progress,
|
|
new ProgressReport
|
|
{
|
|
Phase = NestPhase.Pairs,
|
|
PlateNumber = 3,
|
|
Parts = new List<Part> { source },
|
|
WorkArea = workArea,
|
|
Description = "candidate preview",
|
|
IsOverallBest = true,
|
|
}
|
|
);
|
|
|
|
var reported = Assert.Single(progress.Reports);
|
|
Assert.Equal(NestPhase.Pairs, reported.Phase);
|
|
Assert.Equal(3, reported.PlateNumber);
|
|
Assert.Equal("candidate preview", reported.Description);
|
|
Assert.True(reported.IsOverallBest);
|
|
Assert.Equal(workArea.X, reported.ActiveWorkArea.X);
|
|
Assert.Equal(workArea.Y, reported.ActiveWorkArea.Y);
|
|
Assert.Equal(workArea.Width, reported.ActiveWorkArea.Width);
|
|
Assert.Equal(workArea.Length, reported.ActiveWorkArea.Length);
|
|
|
|
var preview = Assert.Single(reported.BestParts);
|
|
Assert.NotSame(source, preview);
|
|
Assert.Same(source.BaseDrawing, preview.BaseDrawing);
|
|
Assert.Equal(source.Location.X, preview.Location.X);
|
|
Assert.Equal(source.Location.Y, preview.Location.Y);
|
|
Assert.Equal(source.Rotation, preview.Rotation);
|
|
}
|
|
|
|
[Fact]
|
|
public void NestProgressReporter_Report_DoesNotForwardMissingProgressOrParts()
|
|
{
|
|
var progress = new CapturingProgress();
|
|
var part = new Part(new Drawing("part", TestDrawingFactory.Rectangle()));
|
|
|
|
NestProgressReporter.Report(
|
|
null,
|
|
new ProgressReport { Parts = new List<Part> { part } }
|
|
);
|
|
NestProgressReporter.Report(progress, new ProgressReport { Parts = null });
|
|
NestProgressReporter.Report(progress, new ProgressReport { Parts = new List<Part>() });
|
|
|
|
Assert.Empty(progress.Reports);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlateFillOrchestrator_Nest_UsesThresholdIdentityAndCallerDelegates()
|
|
{
|
|
var plate = new Plate(new Size(100, 100));
|
|
var fillDrawing = new Drawing("duplicate", TestDrawingFactory.Rectangle(10, 10));
|
|
var packDrawing = new Drawing("duplicate", TestDrawingFactory.Rectangle(10, 10));
|
|
var items = new List<NestItem>
|
|
{
|
|
new() { Drawing = fillDrawing, Quantity = 10 },
|
|
new() { Drawing = packDrawing, Quantity = 1 },
|
|
};
|
|
var calls = new List<string>();
|
|
var progress = new CapturingProgress();
|
|
var token = CancellationToken.None;
|
|
|
|
var parts = PlateFillOrchestrator.Nest(
|
|
plate,
|
|
items,
|
|
new DefaultFillComparer(),
|
|
(item, workArea, receivedProgress, receivedToken) =>
|
|
{
|
|
calls.Add("fill");
|
|
Assert.Same(progress, receivedProgress);
|
|
Assert.Equal(token, receivedToken);
|
|
Assert.Same(fillDrawing, item.Drawing);
|
|
var placed = new List<Part>();
|
|
for (var i = 0; i < item.Quantity; i++)
|
|
{
|
|
var part = new Part(item.Drawing);
|
|
part.Offset(new Vector(i * 10, 0));
|
|
placed.Add(part);
|
|
}
|
|
return placed;
|
|
},
|
|
(workArea, packItems, receivedProgress, receivedToken) =>
|
|
{
|
|
calls.Add("pack");
|
|
Assert.Same(progress, receivedProgress);
|
|
Assert.Equal(token, receivedToken);
|
|
var packItem = Assert.Single(packItems);
|
|
Assert.Same(packDrawing, packItem.Drawing);
|
|
return new List<Part> { new(packItem.Drawing, new Vector(0, 20)) };
|
|
},
|
|
progress,
|
|
token
|
|
);
|
|
|
|
Assert.Equal(new[] { "fill", "pack" }, calls);
|
|
Assert.Equal(11, parts.Count);
|
|
Assert.Equal(10, parts.Count(part => ReferenceEquals(part.BaseDrawing, fillDrawing)));
|
|
Assert.Single(parts.Where(part => ReferenceEquals(part.BaseDrawing, packDrawing)));
|
|
Assert.Equal(0, items[0].Quantity);
|
|
Assert.Equal(0, items[1].Quantity);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlateFillOrchestrator_Nest_DoesNotInvokeDelegatesAfterCancellation()
|
|
{
|
|
var plate = new Plate(new Size(100, 100));
|
|
var item = new NestItem
|
|
{
|
|
Drawing = new Drawing("part", TestDrawingFactory.Rectangle(10, 10)),
|
|
Quantity = 10,
|
|
};
|
|
using var cancellation = new CancellationTokenSource();
|
|
cancellation.Cancel();
|
|
|
|
var parts = PlateFillOrchestrator.Nest(
|
|
plate,
|
|
new List<NestItem> { item },
|
|
new DefaultFillComparer(),
|
|
(_, _, _, _) => throw new Xunit.Sdk.XunitException("Fill must not run after cancellation"),
|
|
(_, _, _, _) => throw new Xunit.Sdk.XunitException("Pack must not run after cancellation"),
|
|
null,
|
|
cancellation.Token
|
|
);
|
|
|
|
Assert.Empty(parts);
|
|
Assert.Equal(10, item.Quantity);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlateFillOrchestrator_Nest_KeepsTheFullSheetLayoutLessClampHitsWhenItPlacesMore()
|
|
{
|
|
var (plate, drawing) = ClampedPlate();
|
|
var fullSheetFills = 0;
|
|
var stripFilled = false;
|
|
|
|
var parts = PlateFillOrchestrator.Nest(
|
|
plate,
|
|
new List<NestItem> { new() { Drawing = drawing, Quantity = 10 } },
|
|
new DefaultFillComparer(),
|
|
(item, area, _, _) =>
|
|
{
|
|
if (IsFullSheet(area))
|
|
{
|
|
fullSheetFills++;
|
|
return Row(item.Drawing, 0.25, 5, 18, 30, 45, 60);
|
|
}
|
|
if (stripFilled)
|
|
return new List<Part>();
|
|
stripFilled = true;
|
|
return Row(item.Drawing, area.Bottom, 5, 30, 45);
|
|
},
|
|
(_, _, _, _) => new List<Part>(),
|
|
null,
|
|
CancellationToken.None
|
|
);
|
|
|
|
Assert.Equal(1, fullSheetFills);
|
|
Assert.Equal(4, parts.Count);
|
|
Assert.DoesNotContain(parts, part => part.BoundingBox.Left < 21.5 && part.BoundingBox.Right > 20);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlateFillOrchestrator_Nest_SkipsTheFullSheetLayoutOnceDemandIsMet()
|
|
{
|
|
var (plate, drawing) = ClampedPlate();
|
|
|
|
var parts = PlateFillOrchestrator.Nest(
|
|
plate,
|
|
new List<NestItem> { new() { Drawing = drawing, Quantity = 10 } },
|
|
new DefaultFillComparer(),
|
|
(item, area, _, _) =>
|
|
{
|
|
Assert.False(IsFullSheet(area));
|
|
return Row(item.Drawing, area.Bottom, 0.25, 11, 22, 33, 44, 55, 66, 77, 88)
|
|
.Concat(Row(item.Drawing, area.Bottom + 11, 0.25)).ToList();
|
|
},
|
|
(_, _, _, _) => new List<Part>(),
|
|
null,
|
|
CancellationToken.None
|
|
);
|
|
|
|
Assert.Equal(10, parts.Count);
|
|
}
|
|
|
|
private static (Plate Plate, Drawing Drawing) ClampedPlate()
|
|
{
|
|
var plate = new Plate(new Size(100, 100));
|
|
plate.EdgeSpacing = new Spacing(0.25, 0.25);
|
|
plate.ClampZones.Add(new ClampZone("clamp", new Box(20, 0, 1.5, 0.625)));
|
|
return (plate, new Drawing("part", TestDrawingFactory.Rectangle(10, 10)));
|
|
}
|
|
|
|
private static bool IsFullSheet(Box area) => area.Bottom <= 0.25 && area.Length > 99;
|
|
|
|
private static List<Part> Row(Drawing drawing, double y, params double[] xs) =>
|
|
xs.Select(x => new Part(drawing, new Vector(x, y))).ToList();
|
|
|
|
private sealed class FillAndPackProbePlateFiller : PlateFiller
|
|
{
|
|
private readonly Drawing fillDrawing;
|
|
private readonly Drawing packDrawing;
|
|
|
|
internal FillAndPackProbePlateFiller(
|
|
Plate plate,
|
|
Drawing fillDrawing,
|
|
Drawing packDrawing
|
|
)
|
|
: base(plate)
|
|
{
|
|
this.fillDrawing = fillDrawing;
|
|
this.packDrawing = packDrawing;
|
|
}
|
|
|
|
internal int FillCalls { get; private set; }
|
|
|
|
internal int PackAreaCalls { get; private set; }
|
|
|
|
public override List<Part> Fill(
|
|
NestItem item,
|
|
Box workArea,
|
|
IProgress<NestProgress> progress,
|
|
CancellationToken token
|
|
)
|
|
{
|
|
FillCalls++;
|
|
Assert.Same(fillDrawing, item.Drawing);
|
|
var parts = new List<Part>();
|
|
for (var i = 0; i < item.Quantity; i++)
|
|
parts.Add(new Part(fillDrawing, new Vector(i * 10, 0)));
|
|
return parts;
|
|
}
|
|
|
|
public override List<Part> PackArea(
|
|
Box box,
|
|
List<NestItem> items,
|
|
IProgress<NestProgress> progress,
|
|
CancellationToken token
|
|
)
|
|
{
|
|
PackAreaCalls++;
|
|
Assert.Same(packDrawing, Assert.Single(items).Drawing);
|
|
return new List<Part> { new(packDrawing, new Vector(0, 20)) };
|
|
}
|
|
}
|
|
|
|
private sealed class AngleProbePlateFiller : PlateFiller
|
|
{
|
|
internal AngleProbePlateFiller(Plate plate)
|
|
: base(plate) { }
|
|
|
|
internal bool BuildAnglesCalled { get; private set; }
|
|
|
|
public override List<double> BuildAngles(
|
|
NestItem item,
|
|
ClassificationResult classification,
|
|
Box workArea
|
|
)
|
|
{
|
|
BuildAnglesCalled = true;
|
|
return base.BuildAngles(item, classification, workArea);
|
|
}
|
|
}
|
|
|
|
private sealed class PackProbeStripFiller : PlateFiller
|
|
{
|
|
private readonly Drawing drawing;
|
|
|
|
internal PackProbeStripFiller(Plate plate, Drawing drawing)
|
|
: base(plate, PlateFillPolicy.Fill, PlateLayout.Strips)
|
|
{
|
|
this.drawing = drawing;
|
|
}
|
|
|
|
internal int PackAreaCalls { get; private set; }
|
|
|
|
public override List<Part> PackArea(
|
|
Box box,
|
|
List<NestItem> items,
|
|
IProgress<NestProgress> progress,
|
|
CancellationToken token
|
|
)
|
|
{
|
|
PackAreaCalls++;
|
|
Assert.Same(drawing, Assert.Single(items).Drawing);
|
|
return new List<Part> { new(drawing, new Vector(0, 0)) };
|
|
}
|
|
}
|
|
|
|
private static PlateFiller CreateFiller(string strategy, Plate plate) => strategy switch
|
|
{
|
|
"Fill" => new PlateFiller(plate),
|
|
"Vertical Remnant" => new PlateFiller(plate, PlateFillPolicy.VerticalRemnant),
|
|
"Horizontal Remnant" => new PlateFiller(plate, PlateFillPolicy.HorizontalRemnant),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(strategy)),
|
|
};
|
|
|
|
private sealed class CapturingProgress : IProgress<NestProgress>
|
|
{
|
|
public List<NestProgress> Reports { get; } = new();
|
|
|
|
public void Report(NestProgress value)
|
|
{
|
|
Reports.Add(value);
|
|
}
|
|
}
|
|
}
|