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])!;