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;
}