From 5af44b251c1f1fb0b059f03ce9847039d78e1ed2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 6 Oct 2026 12:26:10 -0400 Subject: [PATCH] feat(engine): make Default choose between Irregular and Rectangles per job Default is the engine every front end uses when none is named. It now runs Irregular, then Rectangles, checks both layouts with NestLayoutCheck and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost; ties keep Irregular. A candidate that throws or returns nothing is skipped, cancellation stops the search, and only the chosen layout's plate commits are reported. Neither engine wins every job in the lane benchmarks, and Rectangles adds little time while also covering Irregular's invalid layouts. The registry lists Default first and no longer maps the name to Fill; fill strategy callers still read Default as Fill. A future circle/ring engine joins as another candidate. --- OpenNest.Console/Program.cs | 2 +- .../FillStrategyNameTests.cs | 21 +- .../DefaultNestingEngineTests.cs | 185 ++++++++++++++++++ OpenNest.Engine/Jobs/NestingEngineRegistry.cs | 14 +- .../Default/DefaultNestingEngine.cs | 137 +++++++++++++ README.md | 1 + docs/automatic-nesting.md | 2 +- docs/nesting-engines.md | 14 +- scripts/ReleaseSmoke/Program.cs | 2 +- 9 files changed, 359 insertions(+), 19 deletions(-) create mode 100644 OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs create mode 100644 OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index adc8923..62090ec 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -79,7 +79,7 @@ static class NestConsole // single-plate placement strategy. Unknown names exit with the valid choices. if (options.AutoNest) { - // ResolveName also accepts renamed engines' old names (for example Default). + // ResolveName also accepts renamed engines' old names (for example Opus55NestingEngine). if (NestingEngineRegistry.ResolveName(options.Engine) == null) { Console.Error.WriteLine( diff --git a/OpenNest.Engine.Tests/FillStrategyNameTests.cs b/OpenNest.Engine.Tests/FillStrategyNameTests.cs index d74ad07..bba1c1f 100644 --- a/OpenNest.Engine.Tests/FillStrategyNameTests.cs +++ b/OpenNest.Engine.Tests/FillStrategyNameTests.cs @@ -1,23 +1,25 @@ using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Placement; +using OpenNest.Engine.NestingEngines.Default; using OpenNest.Engine.Tests.Jobs; using OpenNest.Geometry; namespace OpenNest.Engine.Tests; /// -/// The fill engine and placement strategy are named "Fill"; the old name "Default" remains a -/// hidden alias so saved selections, scripts and API requests keep their behavior. +/// The multi-phase fill engine and placement strategy are named "Fill". "Default" names the +/// router engine; fill-strategy callers still read "Default" as Fill. /// public class FillStrategyNameTests { [Fact] - public void FillIsListedAndDefaultIsNot() + public void FillIsListedBesideTheDefaultRouter() { var engines = NestingEngineRegistry.AvailableEngines.Select(e => e.Name).ToList(); + Assert.Equal("Default", engines[0]); Assert.Contains("Fill", engines); - Assert.DoesNotContain("Default", engines, StringComparer.OrdinalIgnoreCase); + Assert.IsType(NestingEngineRegistry.Create("Fill")); Assert.Equal(new[] { "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant" }, PlateFillService.BuiltInStrategies); } @@ -25,22 +27,21 @@ public class FillStrategyNameTests [Theory] [InlineData("Default")] [InlineData(" default ")] - public void LegacyDefaultEngineNameResolvesToFill(string name) + public void DefaultNamesTheRouter(string name) { - Assert.Equal("Fill", NestingEngineRegistry.ResolveName(name)); - Assert.IsType(NestingEngineRegistry.Create(name)); - Assert.Equal(NestJobStatus.Complete, NestingEngineRegistry.Create(name).Solve(FiniteStockJobTests.Job(1)).Status); + Assert.Equal("Default", NestingEngineRegistry.ResolveName(name)); + Assert.IsType(NestingEngineRegistry.Create(name)); } [Fact] - public void PlugInNamedDefaultCannotShadowFill() + public void PlugInNamedDefaultCannotReplaceTheRouter() { var before = NestingEngineRegistry.AvailableEngines.Count; NestingEngineRegistry.Register("Default", "stale plug-in", () => new FixedStrategyNestingEngine("Strip")); Assert.Equal(before, NestingEngineRegistry.AvailableEngines.Count); - Assert.Equal("Fill", NestingEngineRegistry.ResolveName("Default")); + Assert.IsType(NestingEngineRegistry.Create("Default")); } [Theory] diff --git a/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs b/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs new file mode 100644 index 0000000..86f7d5e --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs @@ -0,0 +1,185 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Engine.NestingEngines.Default; +using OpenNest.Engine.NestingEngines.Irregular; +using OpenNest.Engine.NestingEngines.Rectangles; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; +using static OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.NestingEngines; + +public class DefaultNestingEngineTests +{ + // Two 4x4 squares on 10x10 sheets with no spacing: either both on one sheet (cost 100), + // or one per sheet (cost 200), or one placed and one unplaced (100 + a 100 penalty). + private static NestJob SquaresJob() => + Job([Part("p", Rectangle(4, 4), 2, RotationPolicy.Fixed(0))], [Stock("s", 10, 10)]); + + private static NestJobResult Layout(NestJob job, params (double X, double Y)[][] sheets) + { + var builder = new NestJobResultBuilder(job); + foreach (var sheet in sheets) + builder.AddSheet(job.Plates[0], sheet.Select(p => ("p", p.X, p.Y, 0.0))); + return builder.Build(builder.IsComplete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound); + } + + private static (double X, double Y)[] Sheet(params (double X, double Y)[] poses) => poses; + + private static NestJobResult OneSheet(NestJob job) => Layout(job, Sheet((0, 0), (5, 0))); + private static NestJobResult OneSheetHigher(NestJob job) => Layout(job, Sheet((0, 5), (5, 5))); + private static NestJobResult TwoSheets(NestJob job) => Layout(job, Sheet((0, 0)), Sheet((0, 0))); + private static NestJobResult Overlapping(NestJob job) => Layout(job, Sheet((0, 0), (1, 0))); + private static NestJobResult OnePlaced(NestJob job) => Layout(job, Sheet((0, 0))); + + private sealed class Stub(Func?, CancellationToken, NestJobResult> solve) : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, CancellationToken token = default) => + solve(job, progress, token); + } + + private static Func Returns(Func layout) => + () => new Stub((job, _, _) => layout(job)); + + private static NestJobResult Choose(NestJob job, params Func[] candidates) => + new DefaultNestingEngine(candidates).Solve(job); + + [Fact] + public void CheaperValidLayoutWinsInEitherOrder() + { + var job = SquaresJob(); + var cheap = OneSheet(job); + var dear = TwoSheets(job); + + Assert.Same(cheap, Choose(job, Returns(_ => dear), Returns(_ => cheap))); + Assert.Same(cheap, Choose(job, Returns(_ => cheap), Returns(_ => dear))); + } + + [Fact] + public void InvalidLayoutLosesToAValidOneEvenWhenSmaller() + { + var job = SquaresJob(); + var invalid = Overlapping(job); + var valid = TwoSheets(job); + Assert.NotEmpty(NestLayoutCheck.Violations(job, invalid)); + + Assert.Same(valid, Choose(job, Returns(_ => invalid), Returns(_ => valid))); + Assert.Same(valid, Choose(job, Returns(_ => valid), Returns(_ => invalid))); + } + + [Fact] + public void PlacingEveryPartWinsAtEqualCost() + { + var job = SquaresJob(); + var complete = TwoSheets(job); + var partial = OnePlaced(job); + Assert.Equal(NestJobCost.Evaluate(job, complete), NestJobCost.Evaluate(job, partial), 9); + + Assert.Same(complete, Choose(job, Returns(_ => partial), Returns(_ => complete))); + Assert.Same(complete, Choose(job, Returns(_ => complete), Returns(_ => partial))); + } + + [Fact] + public void EqualLayoutsKeepCandidateOrder() + { + var job = SquaresJob(); + var low = OneSheet(job); + var high = OneSheetHigher(job); + + Assert.Same(low, Choose(job, Returns(_ => low), Returns(_ => high))); + Assert.Same(high, Choose(job, Returns(_ => high), Returns(_ => low))); + } + + [Fact] + public void ACrashingCandidateIsSkipped() + { + var job = SquaresJob(); + var valid = TwoSheets(job); + + var result = Choose(job, () => new Stub((_, _, _) => throw new InvalidOperationException("boom")), Returns(_ => valid)); + + Assert.Same(valid, result); + } + + [Fact] + public void ACandidateWithoutAResultIsSkipped() + { + var job = SquaresJob(); + var valid = TwoSheets(job); + + Assert.Same(valid, Choose(job, () => new Stub((_, _, _) => null!), Returns(_ => valid))); + } + + [Fact] + public void WhenEveryCandidateCrashesTheFirstFailureIsRethrown() + { + var job = SquaresJob(); + + var error = Assert.Throws(() => Choose(job, + () => new Stub((_, _, _) => throw new InvalidOperationException("first")), + () => new Stub((_, _, _) => throw new InvalidOperationException("second")))); + + Assert.Equal("first", error.Message); + } + + [Fact] + public void CancellationStopsTheSearch() + { + var job = SquaresJob(); + using var cancellation = new CancellationTokenSource(); + var laterRan = false; + + Assert.ThrowsAny(() => new DefaultNestingEngine(new Func[] + { + () => new Stub((_, _, token) => { cancellation.Cancel(); token.ThrowIfCancellationRequested(); return OneSheet(job); }), + () => { laterRan = true; return new Stub((j, _, _) => OneSheet(j)); }, + }).Solve(job, token: cancellation.Token)); + Assert.False(laterRan); + } + + [Fact] + public void OnlyTheChosenLayoutCommitsAreReported() + { + var job = SquaresJob(); + Func Reporting(Func layout) => () => new Stub((j, progress, _) => + { + progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, "s", 0, 0, 0)); + var result = layout(j); + foreach (var plate in result.Plates) + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, "s", plate.PlateIndex, 99, 99)); + return result; + }); + var reports = new List(); + + var result = new DefaultNestingEngine(new[] { Reporting(TwoSheets), Reporting(OneSheet) }) + .Solve(job, new Capture(reports.Add)); + + Assert.Single(result.Plates); + Assert.Equal(2, reports.Count(r => r.Stage == NestJobStage.EvaluatingCandidate)); + var commit = Assert.Single(reports, r => r.Stage == NestJobStage.PlateCommitted); + Assert.Equal((0, 1, 2), (commit.PlateIndex, commit.CommittedPlates, commit.CommittedParts)); + Assert.Equal(NestJobStage.PlateCommitted, reports[^1].Stage); + } + + [Fact] + public void BuiltInCandidatesReturnTheCheaperOfIrregularAndRectangles() + { + var job = Job([Part("disc", Disc(2), 6), Part("ell", LShape(9, 7, 3), 4), Part("plate", Rectangle(12, 5), 3)], + [Stock("a", 24, 30, 0.25), Stock("b", 30, 40, 0.25)]); + + var result = new DefaultNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + var best = new INestingEngine[] { new IrregularNestingEngine(), new RectanglesNestingEngine() } + .Select(engine => engine.Solve(job)) + .Where(r => NestLayoutCheck.Violations(job, r).Count == 0 && r.Status == NestJobStatus.Complete) + .Min(r => NestJobCost.Evaluate(job, r)); + Assert.Equal(best, NestJobCost.Evaluate(job, result), 9); + } + + private sealed class Capture(Action action) : IProgress + { + public void Report(NestJobProgress value) => action(value); + } +} + +public sealed class DefaultContractTests : EngineContractTests { } diff --git a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs index 6f8b2d5..3c8389e 100644 --- a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs +++ b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs @@ -5,14 +5,16 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; +using OpenNest.Engine.NestingEngines.Default; 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 . Callers choose an +/// Registry of whole-job implementations. "Default" chooses among the +/// built-in engines per job; the four fill 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. /// @@ -28,11 +30,17 @@ public static class NestingEngineRegistry { ["Opus55NestingEngine"] = "Irregular", ["RectanglesNestingEngine"] = "Rectangles", - ["Default"] = "Fill", }; static NestingEngineRegistry() { + // Listed first: the engine front ends use when the caller names none. + Register( + "Default", + "Any job: runs Irregular and Rectangles and keeps the cheapest valid layout", + () => new DefaultNestingEngine() + ); + Register( "Rectangles", "Plain and near-rectangular parts: maximal-rectangles box packing", diff --git a/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs b/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs new file mode 100644 index 0000000..bb6ee1f --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Default/DefaultNestingEngine.cs @@ -0,0 +1,137 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Threading; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.NestingEngines.Irregular; +using OpenNest.Engine.NestingEngines.Rectangles; + +namespace OpenNest.Engine.NestingEngines.Default; + +/// +/// The engine used when the caller names none. It runs each candidate engine on the whole job, +/// checks every result with and returns the best one: a valid +/// layout before an invalid one, then the fewest unplaced parts, then the lowest +/// ; remaining ties keep candidate order. +/// +/// Candidates are Irregular, then Rectangles. Neither wins every job: Rectangles packs each part +/// as its box, which is often best for plain plates and is a cheap, valid fallback for the rest. +/// A candidate that throws or returns no result is skipped; when every candidate throws, the +/// first exception is rethrown. Cancellation stops the search. Candidates' plate commits are not forwarded; the chosen +/// result's commits are reported once it is selected. +/// +/// Deterministic: candidates run one after another and the choice uses no clock. +/// +public sealed class DefaultNestingEngine : INestingEngine +{ + private readonly IReadOnlyList> candidates; + + public DefaultNestingEngine() + : this(new Func[] { () => new IrregularNestingEngine(), () => new RectanglesNestingEngine() }) + { + } + + /// Test seam: candidate engines in tie-break order. + internal DefaultNestingEngine(IReadOnlyList> candidates) + { + ArgumentNullException.ThrowIfNull(candidates); + if (candidates.Count == 0) + throw new ArgumentException("At least one candidate engine is required.", nameof(candidates)); + this.candidates = candidates; + } + + public NestJobResult Solve( + NestJob job, + IProgress? progress = null, + CancellationToken token = default + ) + { + ArgumentNullException.ThrowIfNull(job); + token.ThrowIfCancellationRequested(); + var forward = progress == null ? null : new CandidateProgress(progress); + Scored? best = null; + ExceptionDispatchInfo? firstFailure = null; + + for (var order = 0; order < candidates.Count; order++) + { + token.ThrowIfCancellationRequested(); + NestJobResult? result; + try + { + result = candidates[order]().Solve(job, forward, token); + } + catch (Exception ex) + { + // A cancelled solve is rethrown by the token checks around each candidate. + firstFailure ??= ExceptionDispatchInfo.Capture(ex); + continue; + } + if (result == null) + continue; + + var scored = Scored.Of(job, result, order); + if (best == null || scored.IsBetterThan(best)) + best = scored; + } + + token.ThrowIfCancellationRequested(); + if (best == null) + { + firstFailure?.Throw(); + throw new InvalidOperationException("No candidate engine returned a result."); + } + + ReportCommits(best.Result, progress); + return best.Result; + } + + private static void ReportCommits(NestJobResult result, IProgress? progress) + { + if (progress == null) + return; + var parts = 0; + for (var i = 0; i < result.Plates.Count; i++) + { + var plate = result.Plates[i]; + parts += plate.Placements.Count; + progress.Report(new NestJobProgress(NestJobStage.PlateCommitted, plate.StockId, plate.PlateIndex, i + 1, parts)); + } + } + + private sealed record Scored(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order) + { + public static Scored Of(NestJob job, NestJobResult result, int order) + { + var valid = NestLayoutCheck.Violations(job, result).Count == 0; + var placed = result.Plates.Sum(p => p.Placements.Count); + var unplaced = System.Math.Max(0, job.Parts.Sum(p => p.Quantity) - placed); + // Cost needs every placement to name a known part, which only a valid result guarantees. + var cost = valid ? NestJobCost.Evaluate(job, result) : double.PositiveInfinity; + return new Scored(result, valid, unplaced, cost, order); + } + + public bool IsBetterThan(Scored other) + { + if (Valid != other.Valid) + return Valid; + if (Unplaced != other.Unplaced) + return Unplaced < other.Unplaced; + var scale = System.Math.Max(1, System.Math.Max(System.Math.Abs(Cost), System.Math.Abs(other.Cost))); + if (double.IsFinite(Cost) && double.IsFinite(other.Cost) && System.Math.Abs(Cost - other.Cost) > 1e-9 * scale) + return Cost < other.Cost; + return Order < other.Order; + } + } + + /// Forwards a candidate's progress except plate commits, which belong to the chosen result. + private sealed class CandidateProgress(IProgress inner) : IProgress + { + public void Report(NestJobProgress value) + { + if (value != null && value.Stage != NestJobStage.PlateCommitted) + inner.Report(value); + } + } +} diff --git a/README.md b/README.md index f0afd69..8c39da6 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ Engines implement `INestingEngine.Solve(NestJob)`. Desktop Auto Nest, console au | Engine | Description | |--------|-------------| +| **Default** | Any job: runs Irregular and Rectangles and keeps the cheapest valid layout | | **Rectangles** | Plain and near-rectangular plates: maximal-rectangles box packing | | **Irregular** | Irregular profiles: no-fit-polygon frontier packing | | **Fill** | Multi-phase: linear fill → pairs → rect best-fit → extents (named Default in earlier releases) | diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index 8cc8014..69df1d7 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -42,7 +42,7 @@ The pre-pipeline multi-plate orchestrator and plate-size optimizer have been rem ## Console and MCP -Console `--autonest` uses the selected jobs engine against one physical sheet. The selected plate's old parts are replaced only after acceptance; other plates are unchanged. Default demand is still one of each drawing unless `--quantity` is supplied. A partially fulfilled, valid result may be saved; a successful placement is not a claim that all demand was met. +Console `--autonest` uses the jobs engine named by `--engine` (Default when omitted) against one physical sheet. The selected plate's old parts are replaced only after acceptance; other plates are unchanged. Default demand is still one of each drawing unless `--quantity` is supplied. A partially fulfilled, valid result may be saved; a successful placement is not a claim that all demand was met. Invalid output is printed and rejected with exit code 2 without saving or posting. `--allow-invalid` explicitly accepts representable layout violations. Malformed output, multiple returned sheets, and zero placements are never saved by this path. Unknown engines exit 1. `--autonest --keep-parts` rejects an occupied target: use the plain interactive fill path for existing obstacles instead. diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 3b42ce3..05ae758 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -11,11 +11,18 @@ in `OpenNest.Engine/NestingEngines//`, its tests in `OpenNest.Engine.Tests | Engine | Best for | Method | |---|---|---| +| Default | Any job; used when no engine is named | Runs Irregular, then Rectangles, checks both layouts with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost; ties keep Irregular | | Rectangles | Plain and near-rectangular plates | Each part packed as the box of its material at its minimum-area rotation, using a maximal-rectangles free list; stock chosen sheet by sheet by salvage-credited look-ahead cost | | Irregular | Irregular profiles | No-fit-polygon frontier packing with gap filling and best-fit pairs, six whole-job strategy variants and a tail re-plan | | StockLadder | Caller-supplied stock ladders | Constrained-first fill with equivalent-demand area repacking | | Fill, Strip, Vertical Remnant, Horizontal Remnant | Single-strategy fills | The fixed placement strategies behind interactive fill; Fill is the multi-phase lattice fill (linear, pairs, rectangle best-fit, remainder) | +Neither Irregular nor Rectangles wins every job, even within its own lane, so Default runs both +rather than choosing by part shape; Rectangles adds little time and is also the fallback when an +Irregular layout fails the check. A candidate that throws is skipped. Default routes the whole job: +engines cannot share a sheet, so a job mixing plain and irregular parts goes to both engines whole. +A future circle/ring engine joins Default as another candidate. + Rectangles places irregular parts validly, but only as their bounding boxes; it never nests into a notch or hole. Box sides account for how the layout check flattens arcs, so round-edged parts stay valid at box contact. @@ -85,10 +92,11 @@ desktop selections, scripts and API requests keep working: |---|---| | `Opus55NestingEngine` | Irregular | | `RectanglesNestingEngine` | Rectangles | -| `Default` | Fill | -Fill strategy calls (interactive fill, `PlateFillService`, console fill without `--autonest`, MCP fill -tools) also accept `Default` as Fill. +In v0.3.0 and earlier, `Default` was the multi-phase fill engine now named Fill. `Default` now names the +choosing engine above, so saved selections, scripts and API requests that name it get that +engine. Fill-strategy calls (interactive fill, `PlateFillService`, console fill without +`--autonest`, MCP fill tools) still read `Default` as Fill. Gpt6Astra and Qwen38FlashNext are no longer shipped and have no alias. A saved selection of either falls back to the default engine with the usual status-bar warning. diff --git a/scripts/ReleaseSmoke/Program.cs b/scripts/ReleaseSmoke/Program.cs index 2d59729..d386ac4 100644 --- a/scripts/ReleaseSmoke/Program.cs +++ b/scripts/ReleaseSmoke/Program.cs @@ -20,7 +20,7 @@ try var create = registry.GetMethod("Create")!; // Every built-in engine the desktop offers must instantiate from the packaged assembly. - string[] expected = ["Rectangles", "Irregular", "StockLadder", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant"]; + string[] expected = ["Default", "Rectangles", "Irregular", "StockLadder", "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant"]; foreach (var name in expected) { var engine = create.Invoke(null, [name])!;