From 856dbfd8afa960b0938fc0f2067201321cfd0378 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 21 Sep 2026 19:51:35 -0400 Subject: [PATCH] refactor(engine): add public plate fill service --- .../PlateFillServiceTests.cs | 210 ++++++++++++++++++ .../Jobs/Placement/PlateFillService.cs | 91 ++++++++ 2 files changed, 301 insertions(+) create mode 100644 OpenNest.Engine.Tests/PlateFillServiceTests.cs create mode 100644 OpenNest.Engine/Jobs/Placement/PlateFillService.cs diff --git a/OpenNest.Engine.Tests/PlateFillServiceTests.cs b/OpenNest.Engine.Tests/PlateFillServiceTests.cs new file mode 100644 index 0000000..3ab51f2 --- /dev/null +++ b/OpenNest.Engine.Tests/PlateFillServiceTests.cs @@ -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 { 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 + { + 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(() => + PlateFillService.FillItem(strategy, plate, new NestItem { Drawing = drawing, Quantity = 1 }, plate.WorkArea(), null, CancellationToken.None) + ); + Assert.Throws(() => + PlateFillService.FillGroup(strategy, plate, new List { new(drawing) }, plate.WorkArea(), null, CancellationToken.None) + ); + Assert.Throws(() => + PlateFillService.PackArea(strategy, plate, plate.WorkArea(), new List { 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(() => + PlateFillService.FillItem(null!, plate, new NestItem { Drawing = drawing, Quantity = 1 }, plate.WorkArea(), null, CancellationToken.None) + ); + Assert.Throws(() => + PlateFillService.FillGroup(null!, plate, new List { new(drawing) }, plate.WorkArea(), null, CancellationToken.None) + ); + Assert.Throws(() => + PlateFillService.PackArea(null!, plate, plate.WorkArea(), new List { 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(() => + PlateFillService.FillItem("Default", null!, new NestItem { Drawing = drawing, Quantity = 1 }, new Box(0, 0, 10, 10), null, CancellationToken.None) + ); + } + + public static IEnumerable StrategiesData() => + Strategies.Select(strategy => new object[] { strategy }); + + private sealed class CapturingProgress : IProgress + { + public List Reports { get; } = new(); + + public void Report(NestProgress value) + { + Reports.Add(value); + } + } +} diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs new file mode 100644 index 0000000..7037f46 --- /dev/null +++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs @@ -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; + +/// +/// 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 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. +/// +public static class PlateFillService +{ + /// The four built-in strategy names, in registry display order. + public static IReadOnlyList BuiltInStrategies { get; } = + [ + "Default", + "Strip", + "Vertical Remnant", + "Horizontal Remnant", + ]; + + public static List FillItem( + string strategy, + Plate plate, + NestItem item, + Box workArea, + IProgress progress, + CancellationToken token + ) + { + var filler = CreateFiller(strategy, plate); + return filler.Fill(item, workArea, progress, token); + } + + public static List FillGroup( + string strategy, + Plate plate, + List groupParts, + Box workArea, + IProgress progress, + CancellationToken token + ) + { + var filler = CreateFiller(strategy, plate); + return filler.Fill(groupParts, workArea, progress, token); + } + + public static List PackArea( + string strategy, + Plate plate, + Box box, + List items, + IProgress 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)}." + ); + } +}