refactor(engine): add public plate fill service
This commit is contained in:
@@ -0,0 +1,210 @@
|
|||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
using OpenNest.Engine.Jobs.Placement;
|
||||||
|
using OpenNest.Engine.Tests.Jobs;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Tests;
|
||||||
|
|
||||||
|
public class PlateFillServiceTests
|
||||||
|
{
|
||||||
|
private static readonly string[] Strategies =
|
||||||
|
[
|
||||||
|
"Default",
|
||||||
|
"Strip",
|
||||||
|
"Vertical Remnant",
|
||||||
|
"Horizontal Remnant",
|
||||||
|
];
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(StrategiesData))]
|
||||||
|
public void FillItem_ResolvesBuiltInStrategy_AndRebindsToCallerDrawing(string strategy)
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(60, 80));
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4));
|
||||||
|
var progress = new CapturingProgress();
|
||||||
|
|
||||||
|
var parts = PlateFillService.FillItem(
|
||||||
|
strategy,
|
||||||
|
plate,
|
||||||
|
new NestItem { Drawing = drawing, Quantity = 6 },
|
||||||
|
plate.WorkArea(),
|
||||||
|
progress,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.NotEmpty(parts);
|
||||||
|
Assert.All(parts, part => Assert.Same(drawing, part.BaseDrawing));
|
||||||
|
Assert.NotEmpty(progress.Reports);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FillItem_DoesNotMutateCallerPlate()
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(60, 80));
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4));
|
||||||
|
|
||||||
|
var parts = PlateFillService.FillItem(
|
||||||
|
"Default",
|
||||||
|
plate,
|
||||||
|
new NestItem { Drawing = drawing, Quantity = 6 },
|
||||||
|
plate.WorkArea(),
|
||||||
|
null,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.NotEmpty(parts);
|
||||||
|
Assert.Empty(plate.Parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(StrategiesData))]
|
||||||
|
public void FillGroup_ResolvesBuiltInStrategy(string strategy)
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(60, 80));
|
||||||
|
var drawing = new Drawing("group", TestDrawingFactory.Rectangle(6, 4));
|
||||||
|
var groupParts = new List<Part> { new(drawing), new(drawing) };
|
||||||
|
|
||||||
|
var parts = PlateFillService.FillGroup(
|
||||||
|
strategy,
|
||||||
|
plate,
|
||||||
|
groupParts,
|
||||||
|
plate.WorkArea(),
|
||||||
|
null,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.True(parts.Count >= 2, $"Expected the group template placed at least once, got {parts.Count} parts for '{strategy}'.");
|
||||||
|
Assert.All(parts, part => Assert.Same(drawing, part.BaseDrawing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(StrategiesData))]
|
||||||
|
public void PackArea_ResolvesBuiltInStrategy(string strategy)
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(60, 80));
|
||||||
|
var drawing = new Drawing("packed", TestDrawingFactory.Rectangle(6, 4));
|
||||||
|
var items = new List<NestItem>
|
||||||
|
{
|
||||||
|
new() { Drawing = drawing, Quantity = 3 },
|
||||||
|
};
|
||||||
|
|
||||||
|
var parts = PlateFillService.PackArea(
|
||||||
|
strategy,
|
||||||
|
plate,
|
||||||
|
plate.WorkArea(),
|
||||||
|
items,
|
||||||
|
null,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.NotEmpty(parts);
|
||||||
|
Assert.All(parts, part => Assert.Same(drawing, part.BaseDrawing));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(StrategiesData))]
|
||||||
|
public void FillItem_ReturnsNoParts_WhenTokenIsAlreadyCancelled(string strategy)
|
||||||
|
{
|
||||||
|
// Quantity > 2 keeps the request off the qty 1-2 fast path so the cancellation
|
||||||
|
// check inside the strategy pipeline is actually reached.
|
||||||
|
var plate = new Plate(new Size(60, 80));
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4));
|
||||||
|
var progress = new CapturingProgress();
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
cancellation.Cancel();
|
||||||
|
|
||||||
|
var parts = PlateFillService.FillItem(
|
||||||
|
strategy,
|
||||||
|
plate,
|
||||||
|
new NestItem { Drawing = drawing, Quantity = 4 },
|
||||||
|
plate.WorkArea(),
|
||||||
|
progress,
|
||||||
|
cancellation.Token
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.Empty(parts);
|
||||||
|
Assert.Empty(progress.Reports);
|
||||||
|
Assert.Empty(plate.Parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Mystery Engine")]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("StockLadder")]
|
||||||
|
public void AllOperations_RejectUnknownStrategy(string strategy)
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(60, 80));
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4));
|
||||||
|
|
||||||
|
Assert.Throws<NotSupportedException>(() =>
|
||||||
|
PlateFillService.FillItem(strategy, plate, new NestItem { Drawing = drawing, Quantity = 1 }, plate.WorkArea(), null, CancellationToken.None)
|
||||||
|
);
|
||||||
|
Assert.Throws<NotSupportedException>(() =>
|
||||||
|
PlateFillService.FillGroup(strategy, plate, new List<Part> { new(drawing) }, plate.WorkArea(), null, CancellationToken.None)
|
||||||
|
);
|
||||||
|
Assert.Throws<NotSupportedException>(() =>
|
||||||
|
PlateFillService.PackArea(strategy, plate, plate.WorkArea(), new List<NestItem> { new() { Drawing = drawing, Quantity = 1 } }, null, CancellationToken.None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AllOperations_RejectNullStrategy()
|
||||||
|
{
|
||||||
|
var plate = new Plate(new Size(60, 80));
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4));
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>(() =>
|
||||||
|
PlateFillService.FillItem(null!, plate, new NestItem { Drawing = drawing, Quantity = 1 }, plate.WorkArea(), null, CancellationToken.None)
|
||||||
|
);
|
||||||
|
Assert.Throws<ArgumentNullException>(() =>
|
||||||
|
PlateFillService.FillGroup(null!, plate, new List<Part> { new(drawing) }, plate.WorkArea(), null, CancellationToken.None)
|
||||||
|
);
|
||||||
|
Assert.Throws<ArgumentNullException>(() =>
|
||||||
|
PlateFillService.PackArea(null!, plate, plate.WorkArea(), new List<NestItem> { new() { Drawing = drawing, Quantity = 1 } }, null, CancellationToken.None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FillItem_ResolvesStrategyNames_CaseInsensitively()
|
||||||
|
{
|
||||||
|
// The legacy registry matched ActiveEngineName with OrdinalIgnoreCase; the service
|
||||||
|
// keeps that tolerance for its explicit strategy parameter.
|
||||||
|
var plate = new Plate(new Size(60, 80));
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4));
|
||||||
|
|
||||||
|
var parts = PlateFillService.FillItem(
|
||||||
|
"vertical remnant",
|
||||||
|
plate,
|
||||||
|
new NestItem { Drawing = drawing, Quantity = 4 },
|
||||||
|
plate.WorkArea(),
|
||||||
|
null,
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
Assert.NotEmpty(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FillItem_RejectsNullPlate()
|
||||||
|
{
|
||||||
|
var drawing = new Drawing("part", TestDrawingFactory.Rectangle(6, 4));
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>(() =>
|
||||||
|
PlateFillService.FillItem("Default", null!, new NestItem { Drawing = drawing, Quantity = 1 }, new Box(0, 0, 10, 10), null, CancellationToken.None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<object[]> StrategiesData() =>
|
||||||
|
Strategies.Select(strategy => new object[] { strategy });
|
||||||
|
|
||||||
|
private sealed class CapturingProgress : IProgress<NestProgress>
|
||||||
|
{
|
||||||
|
public List<NestProgress> Reports { get; } = new();
|
||||||
|
|
||||||
|
public void Report(NestProgress value)
|
||||||
|
{
|
||||||
|
Reports.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using OpenNest.Engine.Jobs.Placement.Fillers;
|
||||||
|
using OpenNest.Geometry;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs.Placement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public single-plate placement service over the internal fillers. Resolves one of the four
|
||||||
|
/// built-in placement strategies by explicit name — no process-global registry state is read or
|
||||||
|
/// modified. Operations return proposed <see cref="Part"/>s only; caller-owned plate mutation
|
||||||
|
/// (accepting a preview, adding parts to a plate) and cancel/discard behavior remain with the
|
||||||
|
/// caller, exactly as they were with the legacy single-plate engine surface.
|
||||||
|
/// </summary>
|
||||||
|
public static class PlateFillService
|
||||||
|
{
|
||||||
|
/// <summary>The four built-in strategy names, in registry display order.</summary>
|
||||||
|
public static IReadOnlyList<string> BuiltInStrategies { get; } =
|
||||||
|
[
|
||||||
|
"Default",
|
||||||
|
"Strip",
|
||||||
|
"Vertical Remnant",
|
||||||
|
"Horizontal Remnant",
|
||||||
|
];
|
||||||
|
|
||||||
|
public static List<Part> FillItem(
|
||||||
|
string strategy,
|
||||||
|
Plate plate,
|
||||||
|
NestItem item,
|
||||||
|
Box workArea,
|
||||||
|
IProgress<NestProgress> progress,
|
||||||
|
CancellationToken token
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var filler = CreateFiller(strategy, plate);
|
||||||
|
return filler.Fill(item, workArea, progress, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Part> FillGroup(
|
||||||
|
string strategy,
|
||||||
|
Plate plate,
|
||||||
|
List<Part> groupParts,
|
||||||
|
Box workArea,
|
||||||
|
IProgress<NestProgress> progress,
|
||||||
|
CancellationToken token
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var filler = CreateFiller(strategy, plate);
|
||||||
|
return filler.Fill(groupParts, workArea, progress, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<Part> PackArea(
|
||||||
|
string strategy,
|
||||||
|
Plate plate,
|
||||||
|
Box box,
|
||||||
|
List<NestItem> items,
|
||||||
|
IProgress<NestProgress> progress,
|
||||||
|
CancellationToken token
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var filler = CreateFiller(strategy, plate);
|
||||||
|
return filler.PackArea(box, items, progress, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlateFillerBase CreateFiller(string strategy, Plate plate)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(strategy);
|
||||||
|
ArgumentNullException.ThrowIfNull(plate);
|
||||||
|
|
||||||
|
// OrdinalIgnoreCase mirrors the legacy registry's ActiveEngineName matching so the
|
||||||
|
// interactive callers keep their tolerant name handling while moving off global state.
|
||||||
|
foreach (var candidate in BuiltInStrategies)
|
||||||
|
{
|
||||||
|
if (candidate.Equals(strategy, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return candidate switch
|
||||||
|
{
|
||||||
|
"Default" => new DefaultPlateFiller(plate),
|
||||||
|
"Strip" => new StripPlateFiller(plate),
|
||||||
|
"Vertical Remnant" => new RemnantPlateFiller(plate, RemnantFillPolicy.Vertical),
|
||||||
|
_ => new RemnantPlateFiller(plate, RemnantFillPolicy.Horizontal),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new NotSupportedException(
|
||||||
|
$"Unknown placement strategy: {strategy}. Known strategies: {string.Join(", ", BuiltInStrategies)}."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user