diff --git a/OpenNest.Data/EngineSelectionSettings.cs b/OpenNest.Data/EngineSelectionSettings.cs index 6d7bd42..30789ba 100644 --- a/OpenNest.Data/EngineSelectionSettings.cs +++ b/OpenNest.Data/EngineSelectionSettings.cs @@ -46,14 +46,20 @@ public sealed class EngineSelectionSettings /// /// Resolves against the host's selectable engines AFTER plug-in loading. A missing engine /// returns Default plus a status-bar warning, without replacing the saved preference. - /// Names use registry casing so desktop combo-box selection remains exact. + /// Names use registry casing so desktop combo-box selection remains exact. When the saved + /// name is not selectable, (the registry's legacy-name lookup) + /// may map it to the engine that replaced it; the result must itself be selectable. /// - public string Resolve(IEnumerable availableEngineNames, out string? statusMessage) + public string Resolve( + IEnumerable availableEngineNames, + out string? statusMessage, + Func? renamed = null) { ArgumentNullException.ThrowIfNull(availableEngineNames); + var available = availableEngineNames.ToList(); var requestedName = NormalizeName(EngineName); - var registeredName = availableEngineNames.FirstOrDefault( - name => string.Equals(name, requestedName, StringComparison.OrdinalIgnoreCase)); + var registeredName = Find(available, requestedName) + ?? (renamed?.Invoke(requestedName) is { } replacement ? Find(available, replacement) : null); if (registeredName is not null) { statusMessage = null; @@ -64,6 +70,9 @@ public sealed class EngineSelectionSettings return DefaultEngineName; } + private static string? Find(IEnumerable names, string name) => + names.FirstOrDefault(n => string.Equals(n, name, StringComparison.OrdinalIgnoreCase)); + /// /// Writes camelCase JSON, creating the parent directory and retrying IO collisions as /// LocalJsonProvider does. The host handles a persistent write failure. diff --git a/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs b/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs index dd6929b..0fed19f 100644 --- a/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestingEngineRegistryTests.cs @@ -1,5 +1,5 @@ -using Xunit; using OpenNest.Engine.Jobs; +using Xunit; namespace OpenNest.Engine.Tests.Jobs; @@ -10,12 +10,45 @@ public class NestingEngineRegistryTests { var names = NestingEngineRegistry.AvailableEngines.Select(e => e.Name).ToList(); + Assert.Contains("Rectangles", names); + Assert.Contains("Irregular", names); Assert.Contains("Default", names); Assert.Contains("Strip", names); Assert.Contains("Vertical Remnant", names); Assert.Contains("Horizontal Remnant", names); } + [Fact] + public void RenamedPlugInNamesResolveToTheirBuiltInReplacements() + { + Assert.Equal("Irregular", NestingEngineRegistry.ResolveName("Opus55NestingEngine")); + Assert.Equal("Rectangles", NestingEngineRegistry.ResolveName("rectanglesnestingengine")); + Assert.Equal("Rectangles", NestingEngineRegistry.ResolveName(" rectangles ")); + Assert.IsType( + NestingEngineRegistry.Create("Opus55NestingEngine")); + } + + [Fact] + public void RetiredEnginesAreNotSilentlyAliased() + { + Assert.Null(NestingEngineRegistry.ResolveName("Gpt6AstraNestingEngine")); + Assert.Null(NestingEngineRegistry.ResolveName("Qwen38FlashNextNestingEngine")); + Assert.Null(NestingEngineRegistry.ResolveName(" ")); + Assert.Throws(() => NestingEngineRegistry.Create("Qwen38FlashNextNestingEngine")); + } + + [Fact] + public void LeftoverPlugInUnderARenamedNameCannotShadowItsReplacement() + { + var before = NestingEngineRegistry.AvailableEngines.Count; + + NestingEngineRegistry.Register("Opus55NestingEngine", "stale plug-in", + () => new FixedStrategyNestingEngine("Default")); + + Assert.Equal(before, NestingEngineRegistry.AvailableEngines.Count); + Assert.Equal("Irregular", NestingEngineRegistry.ResolveName("Opus55NestingEngine")); + } + [Fact] public void EachBuiltInFactoryProducesAWorkingEngine() { diff --git a/OpenNest.Engine.Tests/NestingEngines/CurvedExtremeSweepTests.cs b/OpenNest.Engine.Tests/NestingEngines/CurvedExtremeSweepTests.cs new file mode 100644 index 0000000..798c4c3 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/CurvedExtremeSweepTests.cs @@ -0,0 +1,31 @@ +using OpenNest.Engine.NestingEngines.Rectangles; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; +using static OpenNest.Engine.Tests.NestingEngines.Shapes; +using OpenNest.Engine.Jobs; +namespace OpenNest.Engine.Tests.NestingEngines; +/// +/// Curved extremes (discs, rings, obrounds) and sloped ones (triangles) must clear the layout check +/// at every spacing: the check circumscribes arcs, so box-touching copies are only valid when the +/// catalog reads their boxes the way the check does. Failed at 16-50 of these 80 jobs before that. +/// +public class CurvedExtremeSweepTests +{ + [Fact] + public void CurvedAndSlopedPartsPassTheLayoutCheckAtEverySpacing() + { + var bad = new List(); var n = 0; + foreach (var r in new[] { 0.37, 0.5, 0.731, 1.0, 1.23, 2.0, 3.3, 5.0, 7.77, 12.0 }) + foreach (var sp in new[] { 0.0, 0.1, 0.25, 0.3125 }) + foreach (var rot in new[] { false, true }) + foreach (var ring in new[] { false, true }) + { + var pol = rot ? RotationPolicy.Automatic : RotationPolicy.Fixed(0); + var job = Job(new[] { Part("d", ring ? Ring(2 * r, r) : Disc(r), 12, pol), Part("o", Obround(4 * r, 1.3 * r), 6, pol), Part("t", Triangle(3 * r, 2 * r), 4, pol) }, + new[] { Stock("s", 12 * r + 5, 14 * r + 5, spacing: sp) }); + var res = new RectanglesNestingEngine().Solve(job); n++; + var v = NestLayoutCheck.Violations(job, res); + if (v.Count > 0) bad.Add($"r={r} sp={sp} rot={rot} ring={ring}: {v[0]}"); + } + Assert.True(bad.Count == 0, $"{bad.Count}/{n}\n" + string.Join("\n", bad)); + } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs b/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs new file mode 100644 index 0000000..5f3bdb6 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/EngineContractTests.cs @@ -0,0 +1,135 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using Xunit; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; +using static OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.NestingEngines; + +/// Host contract only; engines retain their own packing-quality regressions. +public abstract class EngineContractTests where TEngine : INestingEngine, new() +{ + [Fact] + public void ContractPublicConstructor() => Assert.IsAssignableFrom(Activator.CreateInstance(typeof(TEngine))); + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void ContractQuadrants(int quadrant) + { + var job = Job([Part("disc", Disc(2), 3), Part("ell", LShape(6, 5, 2), 3)], + [Stock("s", 20, 30, 0.2, new Spacing(0.2, 0.3, 0.4, 0.5), quadrant)]); + var result = new TEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void ContractOverflowIndicesAndProgress() + { + var job = Job([Part("p", Rectangle(8, 8), 3)], [Stock("s", 10, 10)]); + var commits = new List(); + var result = new TEngine().Solve(job, new Capture(p => { if (p.Stage == NestJobStage.PlateCommitted) commits.Add(p); })); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(3, result.Plates.Count); + Assert.Equal(Enumerable.Range(0, 3), result.Plates.Select(p => p.PlateIndex)); + Assert.Equal(3, commits.Count); + Assert.Equal(Enumerable.Range(0, 3), commits.Select(p => p.PlateIndex)); + Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedPlates)); + Assert.Equal(Enumerable.Range(1, 3), commits.Select(p => p.CommittedParts)); + Assert.All(result.StockUsage, s => Assert.Null(s.Remaining)); + } + + [Fact] + public void ContractOversize() + { + var job = Job([Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(2, 2), 2)], [Stock("s", 10, 10)]); + var result = new TEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason); + } + + [Fact] + public void ContractLowerNumberPriorityWins() + { + var job = Job([Part("low", Rectangle(8, 8), 1, priority: 9), Part("high", Rectangle(8, 8), 1, priority: 0)], + [Stock("s", 10, 10, quantity: 1)]); + var result = new TEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal("high", Assert.Single(Assert.Single(result.Plates).Placements).PartId); + } + + [Fact] + public void ContractEtchOutsideSheetIsIgnored() + { + var etched = NotchedPartWithEtch(); + etched.Codes.Add(new RapidMove(5, 5)); + etched.Codes.Add(new LinearMove(100, 100) { Layer = LayerType.Scribe }); + var job = Job([Part("p", etched, 1, RotationPolicy.Fixed(0))], [Stock("s", 10.4, 10.4, quantity: 1)]); + var result = new TEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void ContractDeterminism() + { + NestJob Build() => Job([Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12)], + [Stock("a", 30, 45, 0.3), Stock("b", 40, 40, 0.3)]); + var engine = new TEngine(); + var job = Build(); + var first = engine.Solve(job); + var second = engine.Solve(job); + var third = new TEngine().Solve(Build()); + foreach (var result in new[] { first, second, third }) LayoutAssert.Valid(job, result); + Assert.Equal(Describe(first), Describe(second)); + Assert.Equal(Describe(first), Describe(third)); + } + + [Fact] + public void ContractCancellationThrows() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var job = Job([Part("p", Rectangle(2, 2), 5)], [Stock("s", 10, 10)]); + Assert.ThrowsAny(() => new TEngine().Solve(job, token: cancellation.Token)); + } + + [Fact] + public void ContractCancellationDuringSolveThrows() + { + using var cancellation = new CancellationTokenSource(); + var job = Job([Part("p", Rectangle(2, 2), 20)], [Stock("s", 10, 10)]); + var progress = new Capture(p => + { + if (p.Stage == NestJobStage.EvaluatingCandidate) cancellation.Cancel(); + }); + Assert.ThrowsAny(() => new TEngine().Solve(job, progress, cancellation.Token)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ContractStockAndPlateLimits(bool plateLimit) + { + var job = Job([Part("p", Rectangle(8, 8), 3)], + [Stock("s", 10, 10, quantity: plateLimit ? null : 1)], + new NestJobOptions(maxPlates: plateLimit ? 1 : null)); + var result = new TEngine().Solve(job); + LayoutAssert.Valid(job, result); + Assert.Single(result.Plates); + Assert.Equal(2, Assert.Single(result.Fulfillment).Unplaced); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(plateLimit ? NestJobStopReason.PlateLimitReached : NestJobStopReason.StockExhausted, result.StopReason); + } + + private static string Describe(NestJobResult result) => System.Text.Json.JsonSerializer.Serialize(result); + private sealed class Capture(Action action) : IProgress + { public void Report(NestJobProgress value) => action(value); } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularNestingEngineTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularNestingEngineTests.cs new file mode 100644 index 0000000..4555c0a --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularNestingEngineTests.cs @@ -0,0 +1,223 @@ +using OpenNest.Engine.NestingEngines.Irregular; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; +using static OpenNest.Engine.Tests.NestingEngines.Shapes; +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.NestingEngines; + +public class IrregularNestingEngineTests +{ + [Fact] + public void RectanglesFitOnOneSheetWithSpacing() + { + var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) }); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Single(result.Plates); + Assert.Equal(12, result.Plates[0].Placements.Count); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant) + { + var job = Job( + new[] + { + Part("disc", Disc(3), 10), + Part("ell", LShape(12, 8, 4), 10), + Part("tri", Triangle(9, 6), 10), + Part("slot", Obround(10, 3), 6), + }, + new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) } + ); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void ZeroSpacingStillKeepsPartsApartForValidation() + { + var job = Job(new[] { Part("disc", Disc(2), 30), Part("rect", Rectangle(7, 3), 20) }, new[] { Stock("sheet", 30, 40) }); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void LargeAndSmallConcavePartsShareASheet() + { + // End-to-end companion to NoFitCacheTests' containment cases (the precise regression guard). + var job = Job( + new[] { Part("small", LShape(3, 3, 1), 6), Part("big", Rectangle(20, 20), 2) }, + new[] { Stock("sheet", 25, 45, spacing: 0.25) } + ); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void PicksTheCheaperSheetWhenItHoldsEverything() + { + var job = Job( + new[] { Part("square", Rectangle(10, 10), 4) }, + new[] { Stock("big", 60, 120, spacing: 0.25), Stock("small", 25, 25, spacing: 0.25) } + ); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal("small", Assert.Single(result.Plates).StockId); + } + + [Fact] + public void SpillsOntoAdditionalSheets() + { + var job = Job(new[] { Part("rect", Rectangle(20, 10), 25) }, new[] { Stock("sheet", 30, 50, spacing: 0.5) }); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.True(result.Plates.Count > 1); + Assert.Equal(25, result.Plates.Sum(p => p.Placements.Count)); + var indices = result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex).OrderBy(i => i); + Assert.Equal(Enumerable.Range(0, 25), indices); + } + + [Fact] + public void RespectsFixedAndBoundedRotationPolicies() + { + var fixedPolicy = RotationPolicy.Fixed(0); + var sweep = RotationPolicy.BoundedSweep(0, System.Math.PI / 2, System.Math.PI / 4); + var job = Job( + new[] + { + Part("fixed", LShape(10, 6, 3), 8, fixedPolicy), + Part("swept", Triangle(8, 5), 8, sweep), + }, + new[] { Stock("sheet", 40, 60, spacing: 0.25) } + ); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + foreach (var placement in result.Plates.SelectMany(p => p.Placements)) + { + var policy = placement.PartId == "fixed" ? fixedPolicy : sweep; + Assert.True(policy.Allows(placement.Rotation), $"{placement.PartId} at {placement.Rotation}"); + } + } + + [Fact] + public void OversizedPartIsReportedUnplacedWithoutBlockingOthers() + { + var job = Job( + new[] { Part("huge", Rectangle(100, 100), 1), Part("small", Rectangle(5, 5), 3) }, + new[] { Stock("sheet", 20, 20) } + ); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason); + Assert.Equal(1, result.Fulfillment.Single(f => f.PartId == "huge").Unplaced); + Assert.Equal(3, result.Fulfillment.Single(f => f.PartId == "small").Placed); + } + + [Fact] + public void StopsWhenFiniteStockRunsOut() + { + var job = Job(new[] { Part("rect", Rectangle(9, 9), 20) }, new[] { Stock("sheet", 20, 20, quantity: 2) }); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason); + Assert.Equal(2, result.Plates.Count); + var usage = Assert.Single(result.StockUsage); + Assert.Equal(2, usage.Used); + Assert.Equal(0, usage.Remaining); + } + + [Fact] + public void HonorsMaxPlates() + { + var job = Job( + new[] { Part("rect", Rectangle(9, 9), 20) }, + new[] { Stock("sheet", 20, 20) }, + new NestJobOptions(maxPlates: 1) + ); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Single(result.Plates); + Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason); + } + + [Fact] + public void IsDeterministic() + { + NestJob Build() => + Job( + new[] { Part("disc", Disc(2.5), 12), Part("ell", LShape(9, 7, 3), 12), Part("tri", Triangle(7, 7), 12) }, + new[] { Stock("a", 30, 45, spacing: 0.3), Stock("b", 40, 40, spacing: 0.3) } + ); + + var first = new IrregularNestingEngine().Solve(Build()); + var second = new IrregularNestingEngine().Solve(Build()); + + Assert.Equal(System.Text.Json.JsonSerializer.Serialize(first), System.Text.Json.JsonSerializer.Serialize(second)); + } + + [Fact] + public void EtchMarksAreLeftOutOfNestingGeometry() + { + // A bend tick starts on material and ends 1.0 into a side notch, outside the part but + // inside its bounding box (the PEP case that crashed nesting before 1b5e1b1). As + // material it is open geometry leaving the part; as a mark it must be ignored. + var etched = Polyline((0, 0), (10, 0), (10, 4), (8, 4), (8, 6), (10, 6), (10, 10), (0, 10)); + etched.Codes.Add(new RapidMove(7.5, 5)); + etched.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe }); + var job = Job(new[] { Part("part", etched, 2, RotationPolicy.Fixed(0)) }, new[] { Stock("sheet", 10.4, 20.6, spacing: 0.2) }); + + var result = new IrregularNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(2, Assert.Single(result.Plates).Placements.Count); + } + + [Fact] + public void HasPublicParameterlessConstructorForPluginDiscovery() + { + var engine = Activator.CreateInstance(typeof(IrregularNestingEngine)); + Assert.IsAssignableFrom(engine); + } + +} + +public sealed class IrregularContractTests : EngineContractTests { } diff --git a/OpenNest.Engine.Tests/NestingEngines/IrregularNoFitCacheTests.cs b/OpenNest.Engine.Tests/NestingEngines/IrregularNoFitCacheTests.cs new file mode 100644 index 0000000..3cee108 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/IrregularNoFitCacheTests.cs @@ -0,0 +1,69 @@ +using OpenNest.Engine.NestingEngines.Irregular; +using System.Linq; +using Clipper2Lib; +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.NestingEngines; + +public class IrregularNoFitCacheTests +{ + [Theory] + [InlineData(0.0, 0.0)] // B's corner at A's corner: B covers A completely. + [InlineData(-5.0, -5.0)] // A deep inside B. + [InlineData(2.0, 0.5)] // Partial overlap. + public void ForbidsEveryOverlappingOffsetIncludingContainment(double dx, double dy) + { + var (small, big) = Orientations(); + var nfp = new NoFitCache(0.1).Get(small, big); + + Assert.True(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be forbidden"); + } + + [Theory] + [InlineData(4.0, 0.0)] // Beside A, clear by more than the clearance. + [InlineData(0.0, -21.0)] // Below A. + [InlineData(-21.0, 0.0)] // Left of A. + public void AllowsClearOffsets(double dx, double dy) + { + var (small, big) = Orientations(); + var nfp = new NoFitCache(0.1).Get(small, big); + + Assert.False(Forbidden(nfp, new PointD(dx, dy)), $"offset ({dx}, {dy}) should be free"); + } + + /// A = 3x3 L (concave), B = 20x20 square; both at rotation 0 with origin at the lower-left. + private static (Orientation Small, Orientation Big) Orientations() + { + var job = new NestJob( + new[] + { + new NestJobPart("small", Snapshot((0, 0), (3, 0), (3, 1), (1, 1), (1, 3), (0, 3)), 1, 0, RotationPolicy.Fixed(0)), + new NestJobPart("big", Snapshot((0, 0), (20, 0), (20, 20), (0, 20)), 1, 0, RotationPolicy.Fixed(0)), + }, + new[] { new NestPlateStock("s", new Size(100, 100)) } + ); + var types = PartCatalog.Build(job); + return (types[0].Orientations.Single(), types[1].Orientations.Single()); + } + + private static bool Forbidden(Nfp nfp, PointD point) + { + var winding = 0; + foreach (var path in nfp.Region) + if (Clipper.PointInPolygon(point, path) == PointInPolygonResult.IsInside) + winding += Clipper.IsPositive(path) ? 1 : -1; + return winding != 0; + } + + private static PartGeometrySnapshot Snapshot(params (double X, double Y)[] points) + { + var program = new Program(); + program.Codes.Add(new RapidMove(points[0].X, points[0].Y)); + foreach (var (x, y) in points.Skip(1)) + program.Codes.Add(new LinearMove(x, y)); + program.Codes.Add(new LinearMove(points[0].X, points[0].Y)); + return PartGeometrySnapshot.FromProgram(program); + } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/JobBuilder.cs b/OpenNest.Engine.Tests/NestingEngines/JobBuilder.cs new file mode 100644 index 0000000..32d4e92 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/JobBuilder.cs @@ -0,0 +1,26 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.NestingEngines; + +public static class JobBuilder +{ + public static NestJob Job(NestJobPart[] parts, NestPlateStock[] stock, NestJobOptions? options = null) => + new(parts, stock, options); + + public static NestJobPart Part(string id, Program program, int quantity, + RotationPolicy? rotation = null, int priority = 0) => + new(id, PartGeometrySnapshot.FromProgram(program), quantity, priority, rotation); + + /// Y extent. + /// X extent. + public static NestPlateStock Stock(string id, double width, double length, double spacing = 0, + Spacing edge = default, int quadrant = 1, int? quantity = null) => + new(id, new Size(width, length), quantity, spacing, edge, quadrant); + + public static NestJobPart Rectangle(string id, double w, double h, int count, + RotationPolicy? rotation = null, double x = 0, double y = 0) => + Part(id, Shapes.Polyline((x, y), (x + w, y), (x + w, y + h), (x, y + h)), + count, rotation ?? RotationPolicy.Fixed(0)); +} diff --git a/OpenNest.Engine.Tests/NestingEngines/LayoutAssert.cs b/OpenNest.Engine.Tests/NestingEngines/LayoutAssert.cs new file mode 100644 index 0000000..12109f0 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/LayoutAssert.cs @@ -0,0 +1,50 @@ +using OpenNest.Converters; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using Xunit; + +namespace OpenNest.Engine.Tests.NestingEngines; + +public static class LayoutAssert +{ + public static void Valid(NestJob job, NestJobResult result) + { + var violations = NestLayoutCheck.Violations(job, result); + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + Assert.Equal(Enumerable.Range(0, result.Plates.Count), result.Plates.Select(p => p.PlateIndex)); + foreach (var f in result.Fulfillment) + Assert.Equal(f.Requested, f.Placed + f.Unplaced); + foreach (var sheet in result.Plates) + { + var s = sheet.Stock; + var work = s.WorkArea; + foreach (var pose in sheet.Placements) + { + var part = job.Parts.Single(p => p.Id == pose.PartId); + Assert.True(part.Rotation.Allows(pose.Rotation)); + var geometry = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(part.Geometry)) + .Where(e => SpecialLayers.IsMaterial(e.Layer)).ToArray(); + foreach (var entity in geometry) { entity.Rotate(pose.Rotation); entity.Offset(pose.X, pose.Y); } + var b = (L: geometry.Min(e => e.Left), B: geometry.Min(e => e.Bottom), + R: geometry.Max(e => e.Right), T: geometry.Max(e => e.Top)); + Assert.True(b.L >= work.Left - 1e-7 && b.B >= work.Bottom - 1e-7 + && b.R <= work.Right + 1e-7 && b.T <= work.Top + 1e-7); + } + } + foreach (var part in job.Parts) + { + var placed = result.Plates.SelectMany(s => s.Placements).Where(p => p.PartId == part.Id).ToArray(); + Assert.Equal(Enumerable.Range(0, placed.Length), placed.Select(p => p.InstanceIndex).Order()); + var fulfillment = result.Fulfillment.Single(f => f.PartId == part.Id); + Assert.Equal(placed.Length, fulfillment.Placed); + Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced); + } + foreach (var usage in result.StockUsage) + { + var stock = job.Plates.Single(s => s.Id == usage.StockId); + Assert.Equal(result.Plates.Count(s => s.StockId == stock.Id), usage.Used); + Assert.Equal(stock.Quantity - usage.Used, usage.Remaining); + Assert.True(usage.Remaining is null or >= 0); + } + } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs b/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs new file mode 100644 index 0000000..e089e23 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs @@ -0,0 +1,157 @@ +using OpenNest.Engine.NestingEngines.Rectangles; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; +using static OpenNest.Engine.Tests.NestingEngines.Shapes; +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.NestingEngines; + +/// +/// Starter acceptance tests. Every layout is checked by the shared NestLayoutCheck the benchmark +/// scores with, so a passing test means the benchmark will accept the layout. They fail until +/// Solve() is implemented; add engine-specific tests alongside them. +/// +public class RectanglesNestingEngineTests +{ + [Fact] + public void HasPublicParameterlessConstructorForPluginDiscovery() + { + var engine = Activator.CreateInstance(typeof(RectanglesNestingEngine)); + Assert.IsAssignableFrom(engine); + } + + [Fact] + public void RectanglesFitOnOneSheetWithSpacing() + { + var job = Job(new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Single(result.Plates); + Assert.Equal(12, result.Plates[0].Placements.Count); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + public void MixedArcAndConcavePartsAreValidInEveryQuadrant(int quadrant) + { + var job = Job( + new[] + { + Part("disc", Disc(3), 10), + Part("ell", LShape(12, 8, 4), 10), + Part("tri", Triangle(9, 6), 10), + }, + new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5), quadrant: quadrant) } + ); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void OverflowSpillsOntoAdditionalSheets() + { + var job = Job(new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.True(result.Plates.Count > 1); + } + + [Fact] + public void PartTooBigForAnySheetIsReportedUnplaced() + { + var job = Job( + new[] { Part("huge", Rectangle(50, 50), 1), Part("small", Rectangle(5, 5), 4) }, + new[] { Stock("sheet", 20, 20, spacing: 0.25) } + ); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + var huge = Assert.Single(result.Fulfillment, f => f.PartId == "huge"); + Assert.Equal(1, huge.Unplaced); + } + + [Fact] + public void ExactFitGridPacksAtExactlyThePartSpacing() + { + // 4 x 3 boxes of 10 x 5 at 0.5 spacing need exactly 41.5 x 16. + var job = Job(new[] { Part("r", Rectangle(10, 5), 12) }, + new[] { Stock("s", 16, 41.5, spacing: 0.5, quantity: 1) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(12, Assert.Single(result.Plates).Placements.Count); + } + + [Fact] + public void ArcExtremePartsStayClearAtTheSpacing() + { + // Discs and obrounds have arcs, not vertices, at their box edges: the validator's + // circumscribed flattening would read box-touching copies as closer than the spacing. + var job = Job(new[] { Part("disc", Disc(2), 20), Part("ob", Obround(8, 3), 12) }, + new[] { Stock("s", 30, 40, spacing: 0.25) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void MixedSizesFillOneSheetThatShelfPackingWouldSplit() + { + // Area check: 2*(24x20) + 4*(12x10) + 8*(6x5) = 960 + 480 + 240 = 1680 of 48 x 40 = 1920. + // A maximal-rectangles packing fits all of it on one sheet with zero spacing. + var job = Job(new[] + { + Rectangle("big", 24, 20, 2, RotationPolicy.Automatic), + Rectangle("mid", 12, 10, 4, RotationPolicy.Automatic), + Rectangle("small", 6, 5, 8, RotationPolicy.Automatic), + }, + new[] { Stock("s", 40, 48) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Single(result.Plates); + } + + [Fact] + public void RotatedInputIsNestedAtItsMinimumBoundingRectangle() + { + // A 10 x 4 rectangle drawn at 30 degrees: only its squared-up box fits 4 per 20.5 x 8.5 sheet. + var c = System.Math.Cos(System.Math.PI / 6); + var s = System.Math.Sin(System.Math.PI / 6); + (double, double) R(double x, double y) => (x * c - y * s + 5, x * s + y * c + 5); + var tilted = Polyline(R(0, 0), R(10, 0), R(10, 4), R(0, 4)); + var job = Job(new[] { Part("tilted", tilted, 4, RotationPolicy.Automatic) }, + new[] { Stock("s", 8.5, 20.5, spacing: 0.5, quantity: 1) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } +} + +public sealed class RectanglesContractTests : EngineContractTests { } diff --git a/OpenNest.Engine.Tests/NestingEngines/Shapes.cs b/OpenNest.Engine.Tests/NestingEngines/Shapes.cs new file mode 100644 index 0000000..b9c3cbd --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/Shapes.cs @@ -0,0 +1,56 @@ +using OpenNest.CNC; + +namespace OpenNest.Engine.Tests.NestingEngines; + +public static class Shapes +{ + public static Program Polyline(params (double X, double Y)[] points) + { + var program = new Program(); + program.Codes.Add(new RapidMove(points[0].X, points[0].Y)); + foreach (var (x, y) in points.Skip(1)) + program.Codes.Add(new LinearMove(x, y)); + program.Codes.Add(new LinearMove(points[0].X, points[0].Y)); + return program; + } + + public static Program Rectangle(double w, double h) => Polyline((0, 0), (w, 0), (w, h), (0, h)); + + public static Program Triangle(double w, double h) => Polyline((0, 0), (w, 0), (w * 0.3, h)); + + public static Program LShape(double w, double h, double t) => Polyline((0, 0), (w, 0), (w, t), (t, t), (t, h), (0, h)); + + public static Program Disc(double r) + { + var program = new Program(); + program.Codes.Add(new RapidMove(r, 0)); + program.Codes.Add(new ArcMove(-r, 0, 0, 0, RotationType.CCW)); + program.Codes.Add(new ArcMove(r, 0, 0, 0, RotationType.CCW)); + return program; + } + + /// Stadium: two semicircular ends joined by straight sides, offset from the origin. + public static Program Obround(double length, double width) + { + var r = width / 2; + var program = new Program(); + program.Codes.Add(new RapidMove(1 + r, 1)); + program.Codes.Add(new LinearMove(1 + length - r, 1)); + program.Codes.Add(new ArcMove(1 + length - r, 1 + width, 1 + length - r, 1 + r, RotationType.CCW)); + program.Codes.Add(new LinearMove(1 + r, 1 + width)); + program.Codes.Add(new ArcMove(1 + r, 1, 1 + r, 1 + r, RotationType.CCW)); + return program; + } + public static Program NotchedPartWithEtch() + { + var p = new Program(); + p.MoveTo(0, 0); p.LineTo(10, 0); p.LineTo(10, 4); p.LineTo(8, 4); p.LineTo(8, 6); + p.LineTo(10, 6); p.LineTo(10, 10); p.LineTo(0, 10); p.LineTo(0, 0); + p.MoveTo(7.5, 5); + p.Codes.Add(new LinearMove(9, 5) { Layer = LayerType.Scribe }); + return p; + } + + public static Program Ring(double outerDiameter, double innerDiameter) => + new OpenNest.Shapes.RingShape { OuterDiameter = outerDiameter, InnerDiameter = innerDiameter }.GetDrawing().Program; +} diff --git a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs index 87fca0d..26a152d 100644 --- a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs +++ b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs @@ -1,24 +1,49 @@ +#nullable enable using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; +using OpenNest.Engine.NestingEngines.Irregular; +using OpenNest.Engine.NestingEngines.Rectangles; namespace OpenNest.Engine.Jobs; /// /// Registry of whole-job implementations. The four production -/// strategies are exposed through so they compete on -/// equal footing with model-submitted engines. Callers choose an engine explicitly from -/// ; there is no process-global active selection. +/// strategies are exposed through . Callers choose an +/// engine explicitly from ; there is no process-global active +/// selection. Plug-ins loaded from an Engines/ folder register under their CLR type name. /// public static class NestingEngineRegistry { private static readonly List engines = new(); + /// + /// Registry names used by earlier releases, mapped to the engine that replaced them, so saved + /// selections and scripts keep working. Consulted only when no engine has the requested name. + /// + private static readonly Dictionary RenamedEngines = new(StringComparer.OrdinalIgnoreCase) + { + ["Opus55NestingEngine"] = "Irregular", + ["RectanglesNestingEngine"] = "Rectangles", + }; + static NestingEngineRegistry() { + Register( + "Rectangles", + "Plain and near-rectangular parts: maximal-rectangles box packing", + () => new RectanglesNestingEngine() + ); + + Register( + "Irregular", + "Irregular parts: no-fit-polygon frontier packing with look-ahead stock selection", + () => new IrregularNestingEngine() + ); + Register( "StockLadder", "Caller-stock constrained-first fill and equivalent-demand area repacking", @@ -53,13 +78,33 @@ public static class NestingEngineRegistry public static IReadOnlyList AvailableEngines => engines; /// - /// Creates the engine registered under (case-insensitive). The caller's - /// explicit choice is the whole selection mechanism; unknown names throw. + /// Registered name for : an exact (case-insensitive) match, else the + /// engine a renamed legacy name now maps to, else null. Hosts use this to restore a saved + /// selection made under an old name. + /// + public static string? ResolveName(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + var trimmed = name.Trim(); + var info = engines.FirstOrDefault(e => e.Name.Equals(trimmed, StringComparison.OrdinalIgnoreCase)); + if (info != null) + return info.Name; + return RenamedEngines.TryGetValue(trimmed, out var renamed) + && engines.FirstOrDefault(e => e.Name.Equals(renamed, StringComparison.OrdinalIgnoreCase)) is { } target + ? target.Name + : null; + } + + /// + /// Creates the engine registered under (case-insensitive, renamed legacy + /// names accepted). The caller's explicit choice is the whole selection mechanism; unknown names throw. /// public static INestingEngine Create(string name) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - var info = engines.FirstOrDefault(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + var resolved = ResolveName(name); + var info = resolved == null ? null : engines.First(e => e.Name == resolved); if (info == null) throw new NotSupportedException( $"Unknown nesting engine: {name}. Available: {string.Join(", ", engines.Select(e => e.Name))}." @@ -75,6 +120,13 @@ public static class NestingEngineRegistry return; } + // A leftover plug-in under a renamed engine's old name would shadow its built-in replacement. + if (RenamedEngines.ContainsKey(name)) + { + Debug.WriteLine($"[NestingEngineRegistry] '{name}' skipped: replaced by built-in '{RenamedEngines[name]}'"); + return; + } + engines.Add(new NestingEngineInfo(name, description, factory)); } diff --git a/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs new file mode 100644 index 0000000..3a9910d --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/FrontierPacker.cs @@ -0,0 +1,267 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Clipper2Lib; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.NestingEngines.Irregular; + +/// Direction the packing front sweeps across the sheet (the free strip is left behind it). +internal enum PackAxis +{ + /// Front moves in +X; parts settle toward low X, then low Y. + X, + + /// Front moves in +Y; parts settle toward low Y, then low X. + Y, +} + +internal sealed record Placed(Orientation Orientation, double X, double Y) +{ + public double Left => X + Orientation.MinX; + public double Right => X + Orientation.MaxX; + public double Bottom => Y + Orientation.MinY; + public double Top => Y + Orientation.MaxY; +} + +internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList Parts, double PartArea); + +/// +/// Fills one sheet with a frontier-advance rule over incrementally maintained free regions. +/// +/// For every (part type, orientation) still in play the packer keeps the exact set of legal +/// reference points: the inner-fit rectangle of the work area minus the no-fit polygons of +/// everything already placed. Each placement subtracts one translated NFP from each region, +/// so regions only shrink, and a region that empties is retired for the rest of the sheet. +/// +/// Choice rule, applied over all types and orientations at once (not in a fixed order): +/// 1. Gap fill - if any part fits without pushing the packing front forward, place the +/// largest such part at its lowest such point. +/// 2. Otherwise advance - place the part whose front advance per unit area^beta is smallest, +/// i.e. the one that buys the most material coverage for the sheet length it consumes. +/// Parts are never placed in a sequence given up front; the sheet state decides what comes next. +/// +internal sealed class FrontierPacker +{ + /// Slack added around the inner-fit rectangle so zero-width fits survive Clipper; + /// chosen points are clamped back, which moves them far less than the clearance margin. + private const double FitSlack = 2e-4; + + private const double Tie = 1e-6; + + private readonly IReadOnlyList types; + private readonly NoFitCache nfps; + private readonly NestPlateStock stock; + private readonly PackAxis axis; + private readonly double beta; + private readonly Box work; + private readonly WorkCounter counter; + + public FrontierPacker(IReadOnlyList types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter) + { + this.counter = counter; + this.types = types; + this.nfps = nfps; + this.stock = stock; + this.axis = axis; + this.beta = beta; + work = stock.WorkArea; + } + + public SheetFill Fill(IReadOnlyList remaining, CancellationToken token) + { + var left = remaining.ToArray(); + var states = new List(); + foreach (var type in types) + { + if (left[type.Index] <= 0) + continue; + foreach (var o in type.Orientations) + if (stock.Fits(o.Width, o.Height)) + states.Add(new Region(o, work)); + } + + var placed = new List(); + var partArea = 0.0; + var front = axis == PackAxis.X ? work.Left : work.Bottom; + + while (states.Count > 0) + { + token.ThrowIfCancellationRequested(); + var choice = Choose(states, front); + if (choice == null) + break; + + var (region, point) = choice.Value; + var part = new Placed(region.Orientation, point.x, point.y); + placed.Add(part); + var typeIndex = region.Orientation.TypeIndex; + partArea += types[typeIndex].Area; + front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top); + + if (--left[typeIndex] == 0) + states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex); + + // Each surviving region loses the positions the new part now blocks. Regions are + // independent, so they update in parallel without affecting determinism. + var snapshot = states.ToArray(); + counter.Add(snapshot.Length); + Parallel.For( + 0, + snapshot.Length, + new ParallelOptions { CancellationToken = token }, + i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y) + ); + states.RemoveAll(s => s.IsEmpty); + } + + return new SheetFill(stock, placed, partArea); + } + + private (Region, PointD)? Choose(List states, double front) + { + Region? bestRegion = null; + var bestPoint = default(PointD); + var bestFills = false; + var bestValue = double.PositiveInfinity; + var bestSide = double.PositiveInfinity; + var bestLead = double.PositiveInfinity; + var bestPriority = int.MaxValue; + + foreach (var region in states) + { + if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead)) + continue; + var area = types[region.Orientation.TypeIndex].Area; + var priority = types[region.Orientation.TypeIndex].Part.Priority; + if (priority > bestPriority) continue; + var fills = advance <= Tie; + // Gap fill prefers bigger parts (negated area); advance prefers least advance per area. + var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta); + + var better = bestRegion == null + || priority < bestPriority + || (fills && !bestFills) + || ( + fills == bestFills + && ( + value < bestValue - Tie * System.Math.Max(1, System.Math.Abs(bestValue)) + || ( + value <= bestValue + Tie * System.Math.Max(1, System.Math.Abs(bestValue)) + && (side < bestSide - Tie || (side <= bestSide + Tie && lead < bestLead - Tie)) + ) + ) + ); + if (!better) + continue; + bestRegion = region; + bestPriority = priority; + bestPoint = point; + bestFills = fills; + bestValue = value; + bestSide = side; + bestLead = lead; + } + + return bestRegion == null ? null : (bestRegion, bestPoint); + } + + /// Legal reference points for one orientation on this sheet. + private sealed class Region + { + private readonly double minX, minY, maxX, maxY; + private PathsD free; + private RectD bounds; + + public Region(Orientation orientation, Box work) + { + Orientation = orientation; + minX = work.Left - orientation.MinX; + maxX = work.Right - orientation.MaxX; + minY = work.Bottom - orientation.MinY; + maxY = work.Top - orientation.MaxY; + // Guard against fits that are infeasible by less than the bounds tolerance. + if (maxX < minX) + maxX = minX; + if (maxY < minY) + maxY = minY; + free = new PathsD + { + new PathD + { + new(minX - FitSlack, minY - FitSlack), + new(maxX + FitSlack, minY - FitSlack), + new(maxX + FitSlack, maxY + FitSlack), + new(minX - FitSlack, maxY + FitSlack), + }, + }; + bounds = Clipper.GetBounds(free); + } + + public Orientation Orientation { get; } + public bool IsEmpty => free.Count == 0; + + public void Subtract(Nfp nfp, double dx, double dy) + { + if ( + nfp.Bounds.right + dx < bounds.left + || nfp.Bounds.left + dx > bounds.right + || nfp.Bounds.bottom + dy < bounds.top + || nfp.Bounds.top + dy > bounds.bottom + ) + return; + var clip = Clipper.TranslatePaths(nfp.Region, dx, dy); + free = Clipper.Difference(free, clip, FillRule.NonZero, NoFitCache.Precision); + // Drop numerical dust; a sliver thinner than the precision grid is no real room. + free.RemoveAll(p => p.Count < 3); + bounds = free.Count == 0 ? default : Clipper.GetBounds(free); + } + + /// + /// Best vertex of the free region: least front advance, then lowest cross-axis position, + /// then lowest leading edge. Vertices suffice because every score is linear in position. + /// + public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead) + { + point = default; + advance = side = lead = double.PositiveInfinity; + var found = false; + var o = Orientation; + foreach (var path in free) + foreach (var raw in path) + { + var x = System.Math.Clamp(raw.x, minX, maxX); + var y = System.Math.Clamp(raw.y, minY, maxY); + double reach, across, start; + if (axis == PackAxis.X) + { + reach = x + o.MaxX; + across = y + o.MinY; + start = x + o.MinX; + } + else + { + reach = y + o.MaxY; + across = x + o.MinX; + start = y + o.MinY; + } + var adv = System.Math.Max(0, reach - front); + var better = !found + || adv < advance - Tie + || (adv <= advance + Tie && (across < side - Tie || (across <= side + Tie && start < lead - Tie))); + if (!better) + continue; + found = true; + point = new PointD(x, y); + advance = adv; + side = across; + lead = start; + } + return found; + } + } +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs new file mode 100644 index 0000000..dc14d6f --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -0,0 +1,278 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System; +using System.Threading; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.NestingEngines.Irregular; + +/// +/// Frontier-advance NFP packer with look-ahead stock selection. +/// +/// Per sheet, keeps the exact free region of every +/// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places +/// either the largest part that fills a gap behind the packing front, or the part that advances +/// the front least per unit of area covered. Across sheets, every available stock size is +/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the +/// remaining demand at the best efficiency seen) is committed. A handful of deterministic +/// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins. +/// +/// Fully deterministic: no clocks or randomness influence any decision. +/// +public sealed class IrregularNestingEngine : INestingEngine +{ + /// Strategy variants, tried in order: (front direction, area exponent beta). + private static readonly (PackAxis Axis, double Beta)[] Variants = + { + (PackAxis.X, 1.0), + (PackAxis.Y, 1.0), + (PackAxis.X, 0.5), + (PackAxis.Y, 0.5), + (PackAxis.X, 1.5), + (PackAxis.Y, 1.5), + }; + + /// + /// Deterministic work budget, in free-region subtractions, after which no further variant + /// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock. + /// + internal long WorkBudget { get; init; } = 1_500_000; + + public NestJobResult Solve( + NestJob job, + IProgress? progress = null, + CancellationToken token = default + ) + { + ArgumentNullException.ThrowIfNull(job); + token.ThrowIfCancellationRequested(); + var types = PartCatalog.Build(job); + var solver = new Solver(job, types, progress, token); + + // Demand that no offered stock can hold in any allowed orientation is reported unplaced. + var demand = new int[types.Count]; + foreach (var type in types) + { + var placeable = job.Plates.Any(stock => + stock.Quantity != 0 + && type.Orientations.Any(o => stock.Fits(o.Width, o.Height)) + ); + demand[type.Index] = placeable ? type.Part.Quantity : 0; + } + + Plan? best = null; + foreach (var (axis, beta) in Variants) + { + token.ThrowIfCancellationRequested(); + if (best != null && solver.Work.Value >= WorkBudget) + break; + var plan = solver.Plan(demand, axis, beta); + if (best == null || plan.IsBetterThan(best)) + best = plan; + if (best.Unplaced == 0 && best.Sheets.Count == 0) + break; + } + + // The last sheets hold the leftovers, which is where waste concentrates; re-plan them. + best = solver.ImproveTail(best!, WorkBudget * 2); + return BuildResult(job, types, best, progress); + } + + /// Shared state for one solve: job, catalog, NFP caches, effort meter. + private sealed class Solver( + NestJob job, + IReadOnlyList types, + IProgress? progress, + CancellationToken token + ) + { + private const int MaxTail = 3; + private readonly Dictionary caches = new(); + + public WorkCounter Work { get; } = new(); + + private double Penalty => NestJobCost.UnplacedPartPenalty(job); + + public Plan Plan(int[] demand, PackAxis axis, double beta) + { + var run = Decode(demand, axis, beta, new Dictionary(StringComparer.Ordinal), job.Options.MaxPlates, null); + var unplaced = types.Sum(t => t.Part.Quantity) - run.Sheets.Sum(s => s.Parts.Count); + var reason = run.Reason; + if (unplaced > 0 && reason == NestJobStopReason.Completed) + reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold. + return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason); + } + + /// + /// Takes the parts off the last k sheets (k = 1..3) and re-plans just that demand with + /// every stock forced as the first sheet, under every variant; the cheapest complete + /// re-plan that beats the current tail replaces it. Tails are small and effort is metered. + /// + public Plan ImproveTail(Plan plan, long budget) + { + var sheets = plan.Sheets.ToList(); + for (var k = 1; k <= System.Math.Min(MaxTail, sheets.Count); k++) + { + if (Work.Value >= budget) + break; + var prefix = sheets.Take(sheets.Count - k).ToList(); + var tail = sheets.Skip(sheets.Count - k).ToList(); + var tailParts = tail.Sum(s => s.Parts.Count); + var tailNet = tail.Sum(s => NetArea(job.Options, s)); + var tailDemand = new int[types.Count]; + foreach (var part in tail.SelectMany(s => s.Parts)) + tailDemand[part.Orientation.TypeIndex]++; + var used = prefix + .GroupBy(s => s.Stock.Id) + .ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal); + int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null; + + Run? bestRun = null; + var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet); + foreach (var (axis, beta) in Variants) + foreach (var first in job.Plates) + { + token.ThrowIfCancellationRequested(); + var run = Decode(tailDemand, axis, beta, used, cap, first); + if (run.Sheets.Sum(s => s.Parts.Count) != tailParts || run.Net >= bestNet) + continue; + bestRun = run; + bestNet = run.Net; + } + + if (bestRun == null) + continue; + sheets = prefix.Concat(bestRun.Sheets).ToList(); + plan = plan with { Sheets = sheets.ToList(), Cost = plan.Cost - (tailNet - bestRun.Net) }; + } + return plan; + } + + private NoFitCache CacheFor(NestPlateStock stock) + { + var clearance = System.Math.Max(0, stock.PartSpacing); + if (!caches.TryGetValue(clearance, out var cache)) + caches[clearance] = cache = new NoFitCache(clearance); + return cache; + } + + /// + /// Greedy sheet-by-sheet decode. seeds finite-stock + /// accounting, bounds the sheets this run may add, and + /// , when set, forces the stock of the first sheet. + /// + private Run Decode( + int[] demand, + PackAxis axis, + double beta, + IReadOnlyDictionary usedBefore, + int? sheetCap, + NestPlateStock? first + ) + { + var remaining = (int[])demand.Clone(); + var used = job.Plates.ToDictionary(s => s.Id, s => usedBefore.GetValueOrDefault(s.Id), StringComparer.Ordinal); + var sheets = new List(); + var net = 0.0; + NestJobStopReason reason; + + while (true) + { + if (remaining.All(r => r == 0)) + { + reason = NestJobStopReason.Completed; + break; + } + if (sheetCap is int cap && sheets.Count >= cap) + { + reason = NestJobStopReason.PlateLimitReached; + break; + } + + var trials = new List<(SheetFill Fill, double Net)>(); + foreach (var stock in job.Plates) + { + token.ThrowIfCancellationRequested(); + if (sheets.Count == 0 && first != null && !ReferenceEquals(stock, first)) + continue; + if (stock.Quantity is int available && used[stock.Id] >= available) + continue; + progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0)); + var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work); + var fill = packer.Fill(remaining, token); + if (fill.Parts.Count > 0) + trials.Add((fill, NetArea(job.Options, fill))); + } + + if (trials.Count == 0) + { + var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q); + reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound; + break; + } + + // Look-ahead: charge whatever a trial leaves behind at the best efficiency any trial + // achieved, so a sheet that finishes the job competes fairly with a denser partial one. + var remainingArea = types.Sum(t => remaining[t.Index] * t.Area); + var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12)); + var chosen = trials + .Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio)) + .OrderBy(t => t.Estimate) + .ThenByDescending(t => t.Fill.Parts.Count) + .ThenBy(t => t.order) + .First(); + + sheets.Add(chosen.Fill); + net += chosen.Net; + used[chosen.Fill.Stock.Id]++; + foreach (var part in chosen.Fill.Parts) + remaining[part.Orientation.TypeIndex]--; + } + + return new Run(sheets, net, reason); + } + } + + private sealed record Run(IReadOnlyList Sheets, double Net, NestJobStopReason Reason); + + private static NestJobResult BuildResult(NestJob job, IReadOnlyList types, + Plan plan, IProgress? progress) + { + var builder = new NestJobResultBuilder(job, progress); + foreach (var sheet in plan.Sheets) + builder.AddSheet(sheet.Stock, sheet.Parts.Select(p => + (types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation))); + return builder.Build(plan.Reason); + } + + private static double NetArea(NestJobOptions options, SheetFill fill) + { + if (fill.Parts.Count == 0) return fill.Stock.Area; + var left = fill.Parts.Min(p => p.Left); + var bottom = fill.Parts.Min(p => p.Bottom); + return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom, + fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom)); + } + + private sealed record Plan(IReadOnlyList Sheets, double Cost, int Unplaced, NestJobStopReason Reason) + { + public bool IsBetterThan(Plan other) + { + if (Unplaced != other.Unplaced) + return Unplaced < other.Unplaced; + var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost)); + if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale) + return Cost < other.Cost; + return Sheets.Count < other.Sheets.Count; + } + } +} + +/// Deterministic effort meter shared by all packers in one solve. +internal sealed class WorkCounter +{ + private long value; + public long Value => Interlocked.Read(ref value); + public void Add(long amount) => Interlocked.Add(ref value, amount); +} diff --git a/OpenNest.Engine/NestingEngines/Irregular/NoFitCache.cs b/OpenNest.Engine/NestingEngines/Irregular/NoFitCache.cs new file mode 100644 index 0000000..ad7e7bd --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/NoFitCache.cs @@ -0,0 +1,180 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Collections.Concurrent; +using Clipper2Lib; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.NestingEngines.Irregular; + +/// +/// Spacing-inflated footprints and the no-fit polygons between them, for one clearance value. +/// +/// Every placed part owns a footprint: its outline grown by half the required clearance +/// (plus its own chord tolerance). Two parts respect the clearance exactly when their +/// footprints do not overlap, so the whole spacing rule reduces to NFP containment. +/// NFPs are translation-invariant, so each (orientation, orientation) pair is computed once +/// per job and reused by every sheet, stock trial and strategy variant. +/// +internal sealed class NoFitCache +{ + /// Clipper decimal precision; 1e-4 job units is far below any margin we keep. + public const int Precision = NestTolerances.ClipperPrecision; + + private readonly double halfClearance; + private readonly ConcurrentDictionary<(int, int), PathD> footprints = new(); + private readonly ConcurrentDictionary<(int, int, int, int), Lazy> nfps = new(); + + public NoFitCache(double clearance) + { + halfClearance = clearance / 2; + } + + public PathD Footprint(Orientation o) => + footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o)); + + /// NFP of around placed at the origin. + public Nfp Get(Orientation fixedPart, Orientation moving) => + nfps.GetOrAdd( + (fixedPart.TypeIndex, fixedPart.Index, moving.TypeIndex, moving.Index), + _ => new Lazy(() => Build(fixedPart, moving), LazyThreadSafetyMode.ExecutionAndPublication) + ) + .Value; + + private PathD BuildFootprint(Orientation o) + { + // Miter joins (squared past the limit) always contain the exact round offset, so the + // footprint is a superset of "every point within the clearance of the outline". + var inflated = Clipper.InflatePaths( + new PathsD { o.Outline }, + // Four additional grid units cover this engine's repeated footprint/NFP + // Boolean operations. Keep its established contact points and packing quality. + halfClearance + NestTolerances.SafeClearanceMargin(o.Tolerance) / 2 + + 4 * System.Math.Pow(10, -Precision), + JoinType.Miter, + EndType.Polygon, + 2.0, + Precision, + 0.0 + ); + var best = inflated.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).First(); + if (!Clipper.IsPositive(best)) + best.Reverse(); + return best; + } + + private Nfp Build(Orientation fixedPart, Orientation moving) + { + var a = Footprint(fixedPart); + var b = Footprint(moving); + var negB = new PathD(b.Count); + foreach (var p in b) + negB.Add(new PointD(-p.x, -p.y)); + + PathsD region; + if (IsConvex(a) && IsConvex(b)) + { + region = new PathsD { ConvexSum(a, negB) }; + } + else + { + // A (+) P, with P = -B: a reference point the boundary sweep misses puts the moving + // copy of B clear of A's boundary, so that copy is inside A, contains A, or misses it. + // (A + p0) covers "B inside A" and (P + a0) covers "B swallows A"; both are needed. + var sweep = Minkowski.Sum(negB, a, true, Precision); + sweep.Add(Clipper.TranslatePath(a, negB[0].x, negB[0].y)); + sweep.Add(Clipper.TranslatePath(negB, a[0].x, a[0].y)); + region = Clipper.Union(sweep, new PathsD(), FillRule.NonZero, Precision); + } + return new Nfp(region, Clipper.GetBounds(region)); + } + + /// Minkowski sum of two convex CCW polygons by merging edges in angle order. + private static PathD ConvexSum(PathD a, PathD b) + { + var ia = LowestIndex(a); + var ib = LowestIndex(b); + var result = new PathD(a.Count + b.Count); + var current = new PointD(a[ia].x + b[ib].x, a[ia].y + b[ib].y); + int i = 0, j = 0; + while (i < a.Count || j < b.Count) + { + result.Add(current); + var ea = i < a.Count ? Edge(a, ia + i) : default; + var eb = j < b.Count ? Edge(b, ib + j) : default; + // Both edge sequences start at the lowest vertex, so their angles rise through [0, 2pi). + double order; + if (i >= a.Count) + order = -1; + else if (j >= b.Count) + order = 1; + else + { + var difference = EdgeAngle(eb) - EdgeAngle(ea); + order = System.Math.Abs(difference) < 1e-12 ? 0 : difference; + } + if (order > 0) + { + current = new PointD(current.x + ea.x, current.y + ea.y); + i++; + } + else if (order < 0) + { + current = new PointD(current.x + eb.x, current.y + eb.y); + j++; + } + else + { + current = new PointD(current.x + ea.x + eb.x, current.y + ea.y + eb.y); + i++; + j++; + } + } + return result; + } + + private static double EdgeAngle(PointD edge) + { + var angle = System.Math.Atan2(edge.y, edge.x); + return angle < 0 ? angle + System.Math.PI * 2 : angle; + } + + private static PointD Edge(PathD path, int index) + { + var from = path[index % path.Count]; + var to = path[(index + 1) % path.Count]; + return new PointD(to.x - from.x, to.y - from.y); + } + + /// Lowest (then leftmost) vertex: the start of a CCW edge sequence sorted by angle. + private static int LowestIndex(PathD path) + { + var best = 0; + for (var i = 1; i < path.Count; i++) + if (path[i].y < path[best].y || (path[i].y == path[best].y && path[i].x < path[best].x)) + best = i; + return best; + } + + private static bool IsConvex(PathD path) + { + var n = path.Count; + if (n < 3) + return false; + for (var i = 0; i < n; i++) + { + var a = path[i]; + var b = path[(i + 1) % n]; + var c = path[(i + 2) % n]; + var cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x); + if (cross < -1e-12) + return false; + } + return true; + } +} + +/// Forbidden reference-point region (interior = overlap, boundary = touching) and its bounds. +internal sealed record Nfp(PathsD Region, RectD Bounds); diff --git a/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs b/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs new file mode 100644 index 0000000..1600716 --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Irregular/PartCatalog.cs @@ -0,0 +1,161 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Clipper2Lib; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.NestingEngines.Irregular; + +/// +/// One allowed pose of a part type: its rotation, its polygonized outline at that rotation +/// (reference point = snapshot origin), and the outline's conservative bounds. +/// +internal sealed class Orientation +{ + public required int TypeIndex { get; init; } + public required int Index { get; init; } + public required double Rotation { get; init; } + + /// CCW outline whose every point lies within of the true perimeter. + public required PathD Outline { get; init; } + + /// Chord deviation used for arcs; footprints are grown by it to stay conservative. + public required double Tolerance { get; init; } + + /// Outline bounds grown by the tolerance, so they contain the true perimeter. + public required double MinX { get; init; } + public required double MinY { get; init; } + public required double MaxX { get; init; } + public required double MaxY { get; init; } + + public double Width => MaxX - MinX; + public double Height => MaxY - MinY; +} + +internal sealed class PartType +{ + public required int Index { get; init; } + public required NestJobPart Part { get; init; } + public required double Area { get; init; } + public required IReadOnlyList Orientations { get; init; } +} + +/// +/// Converts job snapshots into the polygon world the packer works in. Parts whose geometry +/// cannot be read are kept with no orientations, so they surface as unplaced instead of +/// failing the whole job. +/// +internal static class PartCatalog +{ + /// Finest chord deviation of the working outline from true arcs, in job units. + public const double ChordTolerance = 0.002; + + /// Outline vertex count above which arcs are polygonized more coarsely (NFP cost is ~n*m). + private const int TargetVertices = 64; + + /// Hard cap on distinct orientations evaluated per part type. + private const int MaxOrientations = 8; + + public static IReadOnlyList Build(NestJob job) + { + // Fewer orientations per type for jobs with many distinct parts; every (type, rotation) + // pair costs a feasible-region update per placement. + var perType = System.Math.Clamp(48 / System.Math.Max(1, job.Parts.Count), 2, MaxOrientations); + var types = new List(job.Parts.Count); + for (var index = 0; index < job.Parts.Count; index++) + { + var part = job.Parts[index]; + Shape? perimeter; + try + { + perimeter = ReadPerimeter(part.Geometry); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException) + { + perimeter = null; + } + + if (perimeter == null) + { + types.Add(new PartType { Index = index, Part = part, Area = 0, Orientations = [] }); + continue; + } + + var angles = RotationCandidates.DistinctOutlines(perimeter, + CandidateAngles(part.Rotation, perimeter, perType)); + var tolerance = ChooseTolerance(perimeter); + var orientations = new List(); + foreach (var angle in angles) + { + var outline = Polygonize(perimeter, angle, tolerance); + if (outline.Count < 3) + continue; + orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance)); + } + + var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline)); + types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations }); + } + return types; + } + + private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) => + JobPartGeometry.TryRead(geometry)?.Perimeter; + + /// + /// Coarsens arc polygonization (up to 0.1% of the part size) until the outline is small + /// enough for cheap Minkowski sums. Lines are always exact, so only arc-heavy parts pay. + /// + private static double ChooseTolerance(Shape perimeter) + { + var box = perimeter.BoundingBox; + var cap = System.Math.Max(ChordTolerance, 0.001 * System.Math.Max(box.Width, box.Length)); + var tolerance = ChordTolerance; + while (tolerance * 2 <= cap && perimeter.ToPolygonWithTolerance(tolerance).Vertices.Count > TargetVertices) + tolerance *= 2; + return tolerance; + } + + private static PathD Polygonize(Shape perimeter, double angle, double tolerance) + { + var shape = (Shape)perimeter.Clone(); + if (angle != 0) + shape.Rotate(angle); + var polygon = shape.ToPolygonWithTolerance(tolerance); + var path = new PathD(polygon.Vertices.Count); + foreach (var v in polygon.Vertices) + { + if (path.Count > 0 && System.Math.Abs(path[^1].x - v.X) < 1e-9 && System.Math.Abs(path[^1].y - v.Y) < 1e-9) + continue; + path.Add(new PointD(v.X, v.Y)); + } + if (path.Count > 1 && System.Math.Abs(path[0].x - path[^1].x) < 1e-9 && System.Math.Abs(path[0].y - path[^1].y) < 1e-9) + path.RemoveAt(path.Count - 1); + if (!Clipper.IsPositive(path)) + path.Reverse(); + return path; + } + + private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance) + { + var bounds = Clipper.GetBounds(outline); + return new Orientation + { + TypeIndex = typeIndex, + Index = index, + Rotation = angle, + Outline = outline, + Tolerance = tolerance, + MinX = bounds.left - tolerance, + MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y. + MaxX = bounds.right + tolerance, + MaxY = bounds.bottom + tolerance, + }; + } + + internal static List CandidateAngles(RotationPolicy policy, Shape perimeter, int limit) => + RotationCandidates.ForShape(policy, perimeter, limit).ToList(); +} diff --git a/OpenNest.Engine/NestingEngines/Rectangles/BoxCatalog.cs b/OpenNest.Engine/NestingEngines/Rectangles/BoxCatalog.cs new file mode 100644 index 0000000..2678c97 --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Rectangles/BoxCatalog.cs @@ -0,0 +1,141 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Converters; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; + +namespace OpenNest.Engine.NestingEngines.Rectangles; + +/// One allowed rotation of a part, reduced to its analytic material bounding box. +/// Rotation in radians about the snapshot origin. +/// Material X extent after rotation. +/// Material Y extent after rotation. +/// Rotated material bounds' left edge relative to the snapshot origin. +/// Rotated material bounds' bottom edge relative to the snapshot origin. +internal sealed record BoxOrientation(double Angle, double Width, double Height, double OffsetX, double OffsetY); + +/// A requested part type: every instance shares the same orientations. +internal sealed record BoxType( + int Index, + NestJobPart Part, + IReadOnlyList Orientations, + double MaterialArea) +{ + public string Id => Part.Id; + public int Priority => Part.Priority; + + /// Smallest bounding-box area over the allowed orientations. + public double BoxArea => Orientations.Count == 0 ? 0 : Orientations.Min(o => o.Width * o.Height); + + /// Shortest side over all orientations; free space narrower than this is useless. + public double MinSide => Orientations.Count == 0 ? double.MaxValue + : Orientations.Min(o => System.Math.Min(o.Width, o.Height)); +} + +/// +/// Reduces every requested part to the axis-aligned boxes of its useful rotations. Only material +/// contours count (rapids and scribe/etch marks are excluded), exactly as the layout check's +/// bounds test does. Orientations are the host rotation candidates whose box area is within a +/// hair of the minimum (the minimum-area bounding rectangle plus its right-angle turn), with +/// duplicate box shapes removed. Unreadable geometry yields a type with no orientations. +/// +internal static class BoxCatalog +{ + private const double AreaTieRelative = 1e-6; + private const double DimensionTie = 1e-7; + + public static IReadOnlyList Build(NestJob job) + { + var types = new List(job.Parts.Count); + for (var i = 0; i < job.Parts.Count; i++) + types.Add(Read(i, job.Parts[i])); + return types; + } + + private static BoxType Read(int index, NestJobPart part) + { + var geometry = JobPartGeometry.TryRead(part.Geometry); + if (geometry == null) + return new BoxType(index, part, Array.Empty(), 0); + + var candidates = new List(); + foreach (var angle in RotationCandidates.ForShape(part.Rotation, geometry.Perimeter)) + { + var bounds = RotatedMaterialBounds(part.Geometry, angle); + if (bounds is not { } b || !(b.Width > 0) || !(b.Height > 0)) + continue; + candidates.Add(new BoxOrientation(angle, b.Width, b.Height, b.Left, b.Bottom)); + } + + if (candidates.Count == 0) + return new BoxType(index, part, Array.Empty(), geometry.MaterialArea); + + var minArea = candidates.Min(c => c.Width * c.Height); + var kept = new List(); + foreach (var c in candidates) + { + if (c.Width * c.Height > minArea * (1 + AreaTieRelative)) + continue; + if (kept.Any(k => System.Math.Abs(k.Width - c.Width) <= DimensionTie + && System.Math.Abs(k.Height - c.Height) <= DimensionTie)) + continue; + kept.Add(c); + } + return new BoxType(index, part, kept, geometry.MaterialArea); + } + + /// + /// Material bounds after rotation, as the layout check will see them. The check flattens + /// perimeter arcs circumscribed and snaps to a 1e-4 Clipper grid, so a curved extreme reads + /// slightly outside the true arc. Each side takes the larger of the analytic bound and the + /// check's own outline (ClipperBridge.OffsetForValidation at zero inflation, flattened in the + /// same local frame), and any side where the outline sticks out gets one more grid unit. + /// Straight edges are unchanged, so rectangles still pack at exactly the part spacing. + /// + private static (double Left, double Bottom, double Width, double Height)? RotatedMaterialBounds( + PartGeometrySnapshot snapshot, double angle) + { + var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(snapshot)) + .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ToList(); + if (entities.Count == 0) + return null; + foreach (var entity in entities) + entity.Rotate(angle); + var left = entities.Min(e => e.Left); + var bottom = entities.Min(e => e.Bottom); + var right = entities.Max(e => e.Right); + var top = entities.Max(e => e.Top); + if (!double.IsFinite(left) || !double.IsFinite(bottom) || !double.IsFinite(right) || !double.IsFinite(top)) + return null; + + var profile = new ShapeProfile(entities); + var outline = profile.Perimeter == null ? null + : ClipperBridge.OffsetForValidation(profile, 0, NestTolerances.ValidationOutline).LargestOuter(); + if (outline != null && outline.Vertices.Count >= 3) + { + left = Widen(left, outline.Vertices.Min(v => v.X), -1); + bottom = Widen(bottom, outline.Vertices.Min(v => v.Y), -1); + right = Widen(right, outline.Vertices.Max(v => v.X), +1); + top = Widen(top, outline.Vertices.Max(v => v.Y), +1); + } + return (left, bottom, right - left, top - bottom); + } + + /// + /// Pushes a side out to the check's outline plus one grid unit when the outline sticks out by + /// more than a quarter of the spacing slack. Smaller differences are grid rounding (at most half + /// a unit per vertex) or negligible bulge: two facing sides then lose under 0.00035 in total, + /// inside NestTolerances.SpacingSlack, and ignoring them keeps rotated rectangles exact. + /// + private static double Widen(double analytic, double outline, int direction) + { + var grid = System.Math.Pow(10, -NestTolerances.ClipperPrecision); + var beyond = (outline - analytic) * direction; + return beyond > NestTolerances.SpacingSlack / 4 ? outline + direction * grid : analytic; + } +} diff --git a/OpenNest.Engine/NestingEngines/Rectangles/MaxRectsSheet.cs b/OpenNest.Engine/NestingEngines/Rectangles/MaxRectsSheet.cs new file mode 100644 index 0000000..8d944fb --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Rectangles/MaxRectsSheet.cs @@ -0,0 +1,170 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +namespace OpenNest.Engine.NestingEngines.Rectangles; + +/// Axis-aligned rectangle in sheet-local packing coordinates. +internal readonly record struct Rect(double X, double Y, double W, double H) +{ + public double Right => X + W; + public double Top => Y + H; + + public bool Contains(Rect other) => + other.X >= X - MaxRectsSheet.Eps && other.Y >= Y - MaxRectsSheet.Eps + && other.Right <= Right + MaxRectsSheet.Eps && other.Top <= Top + MaxRectsSheet.Eps; + + public bool Overlaps(Rect other) => + other.X < Right - MaxRectsSheet.Eps && other.Right > X + MaxRectsSheet.Eps + && other.Y < Top - MaxRectsSheet.Eps && other.Top > Y + MaxRectsSheet.Eps; +} + +/// How a free position is scored; lower (Primary, Secondary) wins. +internal enum FitRule +{ + /// Smallest leftover on the tighter side of the free rectangle. + BestShortSide, + /// Smallest leftover on the looser side of the free rectangle. + BestLongSide, + /// Smallest free rectangle that holds the item. + BestArea, + /// Lowest top edge, then leftmost: packs rows upward and keeps a clean top offcut. + BottomLeft, + /// Leftmost right edge, then lowest: packs columns rightward and keeps a clean right offcut. + LeftBottom, + /// Most perimeter touching the sheet edge or already placed items. + ContactPoint, +} + +/// +/// Maximal-rectangles free-space tracker for one sheet (Jylänki, "A Thousand Ways to Pack the +/// Bin", 2010). Keeps every maximal empty rectangle, so any position a box can legally occupy +/// is the bottom-left corner of some free rectangle. Items and the bin are inflated by the part +/// spacing on their right/top sides by the caller, so touching inflated boxes are exactly one +/// spacing apart and the last box may touch the sheet's work-area edge. +/// +internal sealed class MaxRectsSheet +{ + public const double Eps = 1e-9; + + private readonly List free = new(); + private readonly List used = new(); + + public MaxRectsSheet(double width, double height) + { + Width = width; + Height = height; + free.Add(new Rect(0, 0, width, height)); + } + + public double Width { get; } + public double Height { get; } + public IReadOnlyList Used => used; + + /// Best position for a w-by-h item under the rule, or null when nothing holds it. + public (Rect Place, double Primary, double Secondary)? FindBest(double w, double h, FitRule rule) + { + (Rect Place, double Primary, double Secondary)? best = null; + foreach (var f in free) + { + if (w > f.W + Eps || h > f.H + Eps) + continue; + var place = new Rect(f.X, f.Y, w, h); + var (p, s) = Score(f, place, rule); + if (best is not { } b || p < b.Primary - Eps + || (p <= b.Primary + Eps && s < b.Secondary - Eps)) + best = (place, p, s); + } + return best; + } + + /// Commits an item and splits every free rectangle it intersects. + public void Place(Rect item) + { + var next = new List(free.Count + 8); + foreach (var f in free) + { + if (!f.Overlaps(item)) + { + next.Add(f); + continue; + } + if (item.X > f.X + Eps) + next.Add(new Rect(f.X, f.Y, item.X - f.X, f.H)); + if (item.Right < f.Right - Eps) + next.Add(new Rect(item.Right, f.Y, f.Right - item.Right, f.H)); + if (item.Y > f.Y + Eps) + next.Add(new Rect(f.X, f.Y, f.W, item.Y - f.Y)); + if (item.Top < f.Top - Eps) + next.Add(new Rect(f.X, item.Top, f.W, f.Top - item.Top)); + } + free.Clear(); + free.AddRange(Prune(next)); + used.Add(item); + } + + private static List Prune(List rects) + { + // Drop rectangles contained in another; of two equal ones keep the first (deterministic). + var keep = new bool[rects.Count]; + for (var i = 0; i < rects.Count; i++) + keep[i] = rects[i].W > Eps && rects[i].H > Eps; + for (var i = 0; i < rects.Count; i++) + { + if (!keep[i]) + continue; + for (var j = 0; j < rects.Count; j++) + { + if (i == j || !keep[j]) + continue; + if (rects[j].Contains(rects[i]) && (!rects[i].Contains(rects[j]) || j < i)) + { + keep[i] = false; + break; + } + } + } + var result = new List(rects.Count); + for (var i = 0; i < rects.Count; i++) + if (keep[i]) + result.Add(rects[i]); + return result; + } + + private (double Primary, double Secondary) Score(Rect f, Rect place, FitRule rule) + { + var dx = f.W - place.W; + var dy = f.H - place.H; + return rule switch + { + FitRule.BestShortSide => (System.Math.Min(dx, dy), System.Math.Max(dx, dy)), + FitRule.BestLongSide => (System.Math.Max(dx, dy), System.Math.Min(dx, dy)), + FitRule.BestArea => (f.W * f.H - place.W * place.H, System.Math.Min(dx, dy)), + FitRule.BottomLeft => (place.Top, place.X), + FitRule.LeftBottom => (place.Right, place.Y), + FitRule.ContactPoint => (-Contact(place), place.Top + place.Right), + _ => throw new ArgumentOutOfRangeException(nameof(rule)), + }; + } + + private double Contact(Rect r) + { + var total = 0.0; + if (r.X <= Eps) total += r.H; + if (r.Right >= Width - Eps) total += r.H; + if (r.Y <= Eps) total += r.W; + if (r.Top >= Height - Eps) total += r.W; + foreach (var u in used) + { + if (System.Math.Abs(u.X - r.Right) <= Eps || System.Math.Abs(u.Right - r.X) <= Eps) + total += Overlap(u.Y, u.Top, r.Y, r.Top); + if (System.Math.Abs(u.Y - r.Top) <= Eps || System.Math.Abs(u.Top - r.Y) <= Eps) + total += Overlap(u.X, u.Right, r.X, r.Right); + } + return total; + } + + private static double Overlap(double a0, double a1, double b0, double b1) => + System.Math.Max(0, System.Math.Min(a1, b1) - System.Math.Max(a0, b0)); +} diff --git a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs new file mode 100644 index 0000000..33e589c --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs @@ -0,0 +1,154 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.NestingEngines.Rectangles; + +/// +/// Rectangle-lane nesting engine: every part is nested as the axis-aligned box of its material at +/// its minimum-area rotations, packed with a maximal-rectangles free list (Jylänki 2010). +/// +/// Built for jobs of plain and near-rectangular parts, where a part's box wastes almost nothing +/// and exact box packing beats contour-sliding engines on both speed and density. Irregular parts +/// are still placed validly, only as their bounding boxes; they are not nested into each other. +/// +/// Sheet by sheet, each available stock is packed under several free-space scoring rules and +/// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with +/// the lowest estimated whole-job cost wins: its salvage-credited net area (NestJobCost) plus the +/// remaining demand priced at the best net-area-per-part-area ratio seen among the candidates. +/// Deterministic: no clocks or randomness; the only stop besides completion is the host token. +/// +public sealed class RectanglesNestingEngine : INestingEngine +{ + private static readonly FitRule[] Rules = + { + FitRule.BestShortSide, FitRule.BestLongSide, FitRule.BestArea, + FitRule.BottomLeft, FitRule.LeftBottom, FitRule.ContactPoint, + }; + + private static readonly PickMode[] Modes = { PickMode.Global, PickMode.Ordered }; + + public NestJobResult Solve( + NestJob job, + IProgress? progress = null, + CancellationToken token = default + ) + { + ArgumentNullException.ThrowIfNull(job); + token.ThrowIfCancellationRequested(); + + var types = BoxCatalog.Build(job); + var remaining = types.Select(t => t.Part.Quantity).ToArray(); + // Parts with unreadable geometry or no box that fits any offered sheet can never be placed. + foreach (var t in types) + if (t.Orientations.Count == 0 || !job.Plates.Any(stock => t.Orientations.Any(o => FitsStock(stock, o)))) + remaining[t.Index] = 0; + + var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal); + var result = new NestJobResultBuilder(job, progress); + NestJobStopReason reason; + + while (true) + { + if (remaining.All(r => r == 0)) + { + reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met. + break; + } + if (job.Options.MaxPlates is int cap && result.SheetsUsed(job) >= cap) + { + reason = NestJobStopReason.PlateLimitReached; + break; + } + + var trials = new List<(SheetPlan Plan, double Net)>(); + foreach (var stock in job.Plates) + { + token.ThrowIfCancellationRequested(); + if (stock.Quantity is int available && used[stock.Id] >= available) + continue; + progress?.Report(new NestJobProgress( + NestJobStage.EvaluatingCandidate, stock.Id, result.SheetsUsed(job), result.SheetsUsed(job), 0)); + foreach (var mode in Modes) + foreach (var rule in Rules) + { + var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token); + if (plan.Parts.Count > 0) + trials.Add((plan, NetArea(job, plan))); + } + } + + if (trials.Count == 0) + { + var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q); + reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound; + break; + } + + var chosen = Choose(types, remaining, trials); + result.AddSheet(chosen.Stock, chosen.Poses()); + used[chosen.Stock.Id]++; + foreach (var p in chosen.Parts) + remaining[p.Type.Index]--; + } + + return result.Build(reason); + } + + /// + /// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the + /// best net-area-per-material ratio any candidate achieved, so a sheet that finishes the job + /// competes fairly with a denser partial one. Ties: more material placed, then enumeration order. + /// + private static SheetPlan Choose( + IReadOnlyList types, int[] remaining, List<(SheetPlan Plan, double Net)> trials) + { + var demandArea = types.Sum(t => remaining[t.Index] * t.MaterialArea); + var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12)); + return trials + .Select((t, order) => (t.Plan, order, + Estimate: t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio)) + .OrderBy(t => PriorityDebt(types, remaining, t.Plan)) + .ThenBy(t => t.Estimate) + .ThenByDescending(t => t.Plan.MaterialArea) + .ThenBy(t => t.order) + .First() + .Plan; + } + + /// + /// Priority guard: how many instances of the most urgent (lowest-number) tier with remaining + /// demand this plan leaves unplaced. Plans are ranked on this before cost, so a cheaper sheet + /// can never win by serving a later tier at the expense of an earlier one. + /// + private static int PriorityDebt(IReadOnlyList types, int[] remaining, SheetPlan plan) + { + var active = types.Where(t => remaining[t.Index] > 0).ToList(); + if (active.Count == 0) + return 0; + var top = active.Min(t => t.Priority); + var placed = plan.Parts.Count(p => p.Type.Priority == top); + return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed; + } + + private static double NetArea(NestJob job, SheetPlan plan) => + plan.Envelope is { } envelope + ? NestJobCost.NetSheetArea(job.Options, plan.Stock, envelope) + : plan.Stock.Area; + + private static bool FitsStock(NestPlateStock stock, BoxOrientation o) + { + var work = stock.WorkArea; + return o.Width <= work.Right - work.Left + MaxRectsSheet.Eps + && o.Height <= work.Top - work.Bottom + MaxRectsSheet.Eps; + } +} + +internal static class ResultBuilderExtensions +{ + public static int SheetsUsed(this NestJobResultBuilder builder, NestJob job) => + job.Plates.Sum(builder.SheetsUsed); +} diff --git a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs new file mode 100644 index 0000000..78d9fa6 --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs @@ -0,0 +1,170 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.NestingEngines.Rectangles; + +/// How the next box is chosen on a sheet. +internal enum PickMode +{ + /// Every step places whichever remaining type/orientation scores best anywhere. + Global, + /// Types in (priority, largest box first) order; each fills until it no longer fits. + Ordered, +} + +/// One placed box: which part type, which orientation, and its material bounds' corner. +internal readonly record struct Placed(BoxType Type, BoxOrientation Orientation, double Left, double Bottom); + +/// A proposed single-sheet layout. +internal sealed record SheetPlan( + NestPlateStock Stock, + IReadOnlyList Parts, + double MaterialArea, + Box? Envelope, + FitRule Rule, + PickMode Mode) +{ + /// Converts box corners into job poses (rotate about the snapshot origin, then translate). + public IEnumerable<(string PartId, double X, double Y, double Rotation)> Poses() => + Parts.Select(p => (p.Type.Id, p.Left - p.Orientation.OffsetX, p.Bottom - p.Orientation.OffsetY, + p.Orientation.Angle)); +} + +/// +/// Packs the remaining demand onto one sheet of the given stock with a maximal-rectangles free +/// list. Lower priority numbers are always served first: a higher-number type is only placed +/// when no lower-number type still fits anywhere. +/// +internal static class SheetPacker +{ + public static SheetPlan Pack( + IReadOnlyList types, IReadOnlyList remaining, NestPlateStock stock, + FitRule rule, PickMode mode, CancellationToken token) + { + var work = stock.WorkArea; + var s = stock.PartSpacing; + var sheet = new MaxRectsSheet(work.Right - work.Left + s, work.Top - work.Bottom + s); + var left = remaining.ToArray(); + var placed = new List(); + + if (mode == PickMode.Global) + PackGlobal(types, left, sheet, s, rule, placed, token); + else + PackOrdered(types, left, sheet, s, rule, placed, token); + + var area = 0.0; + Box? envelope = null; + foreach (var p in placed) + { + area += p.Type.MaterialArea; + var box = new Box(work.Left + p.Left, work.Bottom + p.Bottom, p.Orientation.Width, p.Orientation.Height); + envelope = envelope == null ? box : Union(envelope, box); + } + + var world = placed + .Select(p => p with { Left = work.Left + p.Left, Bottom = work.Bottom + p.Bottom }) + .ToList(); + return new SheetPlan(stock, world, area, envelope, rule, mode); + } + + private static void PackGlobal( + IReadOnlyList types, int[] left, MaxRectsSheet sheet, double s, FitRule rule, + List placed, CancellationToken token) + { + // Free space only shrinks, so an orientation that fails once never fits again on this sheet. + var dead = types.Select(t => new bool[t.Orientations.Count]).ToArray(); + var tiers = types.Select(t => t.Priority).Distinct().Order().ToArray(); + + while (true) + { + token.ThrowIfCancellationRequested(); + (BoxType Type, int Orientation, Rect Place, double P, double S)? best = null; + foreach (var tier in tiers) + { + foreach (var type in types) + { + if (type.Priority != tier || left[type.Index] == 0) + continue; + for (var o = 0; o < type.Orientations.Count; o++) + { + if (dead[type.Index][o]) + continue; + var orientation = type.Orientations[o]; + var fit = sheet.FindBest(orientation.Width + s, orientation.Height + s, rule); + if (fit is not { } f) + { + dead[type.Index][o] = true; + continue; + } + if (best is not { } b || Better(f.Primary, f.Secondary, type.BoxArea, b.P, b.S, b.Type.BoxArea)) + best = (type, o, f.Place, f.Primary, f.Secondary); + } + } + if (best != null) + break; + } + + if (best is not { } chosen) + return; + sheet.Place(chosen.Place); + left[chosen.Type.Index]--; + placed.Add(new Placed(chosen.Type, chosen.Type.Orientations[chosen.Orientation], chosen.Place.X, chosen.Place.Y)); + } + } + + private static void PackOrdered( + IReadOnlyList types, int[] left, MaxRectsSheet sheet, double s, FitRule rule, + List placed, CancellationToken token) + { + var order = types + .Where(t => t.Orientations.Count > 0) + .OrderBy(t => t.Priority) + .ThenByDescending(t => t.BoxArea) + .ThenBy(t => t.Index); + + foreach (var type in order) + { + while (left[type.Index] > 0) + { + token.ThrowIfCancellationRequested(); + (int Orientation, Rect Place, double P, double S)? best = null; + for (var o = 0; o < type.Orientations.Count; o++) + { + var orientation = type.Orientations[o]; + var fit = sheet.FindBest(orientation.Width + s, orientation.Height + s, rule); + if (fit is { } f && (best is not { } b || Better(f.Primary, f.Secondary, 0, b.P, b.S, 0))) + best = (o, f.Place, f.Primary, f.Secondary); + } + if (best is not { } chosen) + break; + sheet.Place(chosen.Place); + left[type.Index]--; + placed.Add(new Placed(type, type.Orientations[chosen.Orientation], chosen.Place.X, chosen.Place.Y)); + } + } + } + + /// Lower score wins; on a tie the larger box goes first (strict, so input order breaks full ties). + private static bool Better(double p, double s, double area, double bp, double bs, double barea) + { + if (p < bp - MaxRectsSheet.Eps) return true; + if (p > bp + MaxRectsSheet.Eps) return false; + if (s < bs - MaxRectsSheet.Eps) return true; + if (s > bs + MaxRectsSheet.Eps) return false; + return area > barea + MaxRectsSheet.Eps; + } + + private static Box Union(Box a, Box b) + { + var l = System.Math.Min(a.Left, b.Left); + var bo = System.Math.Min(a.Bottom, b.Bottom); + var r = System.Math.Max(a.Right, b.Right); + var t = System.Math.Max(a.Top, b.Top); + return new Box(l, bo, r - l, t - bo); + } +} diff --git a/OpenNest.Tests/Data/EngineSelectionSettingsTests.cs b/OpenNest.Tests/Data/EngineSelectionSettingsTests.cs index 7457510..f4e95f3 100644 --- a/OpenNest.Tests/Data/EngineSelectionSettingsTests.cs +++ b/OpenNest.Tests/Data/EngineSelectionSettingsTests.cs @@ -167,6 +167,31 @@ public class EngineSelectionSettingsTests : IDisposable Assert.Null(message); } + [Fact] + public void Resolve_RenamedEngineMapsToItsReplacementWithoutRewritingSavedChoice() + { + new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path); + var settings = EngineSelectionSettings.Load(_path); + + var resolved = settings.Resolve(new[] { "Default", "Irregular" }, out var message, + name => name == "Opus55NestingEngine" ? "irregular" : null); + + Assert.Equal("Irregular", resolved); + Assert.Null(message); + Assert.Equal("Opus55NestingEngine", EngineSelectionSettings.Load(_path).EngineName); + } + + [Fact] + public void Resolve_RenameToAnUnselectableEngineStillFallsBackWithMessage() + { + var settings = new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }; + + var resolved = settings.Resolve(new[] { "Default", "Strip" }, out var message, _ => "Irregular"); + + Assert.Equal("Default", resolved); + Assert.Equal("Saved Auto Nest engine 'Opus55NestingEngine' is unavailable. Using Default.", message); + } + [Fact] public void Save_UnwritablePathReportsFailureToCaller() { diff --git a/OpenNest/Forms/EngineSelection.cs b/OpenNest/Forms/EngineSelection.cs index 0062bcf..85d26de 100644 --- a/OpenNest/Forms/EngineSelection.cs +++ b/OpenNest/Forms/EngineSelection.cs @@ -55,7 +55,7 @@ namespace OpenNest.Forms public static string LoadSavedSelection() { var settings = EngineSelectionSettings.Load(EngineSelectionSettings.DefaultPath); - engineName = settings.Resolve(UiEngineNames, out var statusMessage); + engineName = settings.Resolve(UiEngineNames, out var statusMessage, NestingEngineRegistry.ResolveName); return statusMessage ?? string.Empty; }