diff --git a/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs index be145da8..889292d8 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobCostTests.cs @@ -40,6 +40,28 @@ public class NestJobCostTests #pragma warning restore CS0618 } + [Theory] + [InlineData(0, 0, 247.5)] + [InlineData(0.5, 0, 247.5)] + [InlineData(0, 13.75, 247.5)] + [InlineData(0, 14, 0)] + public void CompactnessOffcutIgnoresSalvageRateButKeepsTheMinimum(double rate, double minimum, double expected) + { + var stock = new NestPlateStock("sheet", new Size(20, 20), partSpacing: 0.25, + edgeSpacing: new Spacing(1, 1, 1, 1)); + var work = stock.WorkArea; + var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(18, 4)), 1); + var job = new NestJob(new[] { part }, new[] { stock }, + new NestJobOptions(salvageRate: rate, minimumSalvageDimension: minimum)); + var sheet = new NestJobPlateResult(0, stock, + new[] { new NestJobPlacement("part", 0, work.Left, work.Bottom, 0) }); + + // The strip above the part: full work width by 18 - 4 - 0.25 spacing. + Assert.Equal(expected, NestJobCost.CompactnessOffcut(job, sheet)); + Assert.Equal(expected, NestJobCost.CompactnessOffcut(job.Options, stock, new Box(work.Left, work.Bottom, 18, 4))); + Assert.Equal(0, NestJobCost.CompactnessOffcut(job, new NestJobPlateResult(0, stock, Array.Empty()))); + } + [Theory] [InlineData(0, 5, 100)] [InlineData(0.5, 0, 100)] diff --git a/OpenNest.Engine.Tests/NestingEngines/CompactTieBreakTests.cs b/OpenNest.Engine.Tests/NestingEngines/CompactTieBreakTests.cs new file mode 100644 index 00000000..cf3fe145 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/CompactTieBreakTests.cs @@ -0,0 +1,42 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Tests.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.NestingEngines; + +/// +/// Without salvage credit (desktop Auto Nest on one plate size) every complete one-sheet layout +/// costs the same, so engines must break the tie toward the larger free offcut. Shaped after +/// N26-23S: four 7.083 x 22.577 and four 6.236 x 18.688 boxes on a clamped 60 x 120 sheet, +/// which once nested 27.86" long when turning two of the smaller boxes fits it in 21.3". +/// +public class CompactTieBreakTests +{ + [Theory] + [InlineData("Rectangles")] + [InlineData("Irregular")] + [InlineData("Default")] + public void EqualCostOneSheetLayoutsPreferTheLargestOffcut(string engine) + { + var template = new Plate(60, 120) + { + PartSpacing = 0.3, + EdgeSpacing = new Spacing(0.3, 0.75, 0.3, 0.25), + }; + template.SetClampRail(new ClampRail(1.5, 0.625, new double[] { 20, 40, 60, 80, 100, 120, 140 })); + var items = new[] + { + new NestItem { Drawing = new Drawing("leg-bottom", TestDrawingFactory.Rectangle(7.083, 22.577)), Quantity = 4 }, + new NestItem { Drawing = new Drawing("leg-top", TestDrawingFactory.Rectangle(6.236, 18.688)), Quantity = 4 }, + }; + + var result = NestPipeline.Run(new NestPipelineRequest(engine, items, + NestStockBuilder.FromTemplate(template, null), + new NestJobOptions(maxPlates: 100, salvageRate: 0, minimumSalvageDimension: 0))); + + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + var parts = Assert.Single(result.Plates).Parts; + Assert.Equal(8, parts.Count); + Assert.True(parts.Max(p => p.BoundingBox.Right) < 21.5, $"used length {parts.Max(p => p.BoundingBox.Right):0.###}"); + } +} diff --git a/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs b/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs index b107f230..61001bbe 100644 --- a/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/DefaultNestingEngineTests.cs @@ -25,6 +25,9 @@ public class DefaultNestingEngineTests 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))); + // Same 10 x 6 free strip as OneSheet, below the parts instead of above them. + private static NestJobResult OneSheetAtTop(NestJob job) => Layout(job, Sheet((0, 6), (5, 6))); + // Only a 10 x 5 strip left below the parts. 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))); @@ -82,12 +85,24 @@ public class DefaultNestingEngineTests { var job = SquaresJob(); var low = OneSheet(job); - var high = OneSheetHigher(job); + var high = OneSheetAtTop(job); Assert.Same(low, Choose(job, Returns(_ => low), Returns(_ => high))); Assert.Same(high, Choose(job, Returns(_ => high), Returns(_ => low))); } + [Fact] + public void LargerOffcutWinsAtEqualCostInEitherOrder() + { + var job = SquaresJob(); + var compact = OneSheet(job); + var loose = OneSheetHigher(job); + Assert.Equal(NestJobCost.Evaluate(job, compact), NestJobCost.Evaluate(job, loose)); + + Assert.Same(compact, Choose(job, Returns(_ => loose), Returns(_ => compact))); + Assert.Same(compact, Choose(job, Returns(_ => compact), Returns(_ => loose))); + } + [Fact] public void ACrashingCandidateIsSkipped() { diff --git a/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs b/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs index 5cf4cb11..9ca9baa2 100644 --- a/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs @@ -35,17 +35,19 @@ public class RectanglesClampZoneTests } [Theory] - // Pre-clamp baseline hashes, captured from the unchanged packer at commit a3f1124. - [InlineData("1FA61E933D2D0D6F1E3336FCEBBB21CEB7E7D1533FDB253F9D48E947217575CE")] + // Pre-clamp baseline hashes, captured from the unchanged packer at commit a3f1124 and + // re-captured when equal-cost sheet layouts began preferring the larger free offcut. + [InlineData("3D389D6B993B69634EB45FC2CB0309273DE1BCF57A7C2BD980C63B535667B0C6")] public void ZeroClampLayoutIsBitIdenticalToBaseline(string hash) => Assert.Equal(hash, LayoutHash(Engine().Solve(Job( new[] { Part("rect", Rectangle(10, 5), 12) }, new[] { Stock("sheet", 48, 96, spacing: 0.25) })))); [Theory] - // Two complete candidates tie here on cost and placed material; the tie must fall to - // enumeration order on every platform (hash identical on Windows and Linux). - [InlineData("BEEEDEC81A7F987720FB5F331873103AD2470C839D568CE587F230BC39D210CC")] + // Complete candidates tie here on cost and placed material; the tie must fall to the + // compactness offcut, then enumeration order, the same way on every platform (hash + // identical on Windows and Linux). + [InlineData("9B343B6C5CF2CE8753F2E04ACB0A9C4D5921F4ADC15AA2586AF466769C0F019B")] public void ZeroClampMixedShapeLayoutIsBitIdenticalToBaseline(string hash) => Assert.Equal(hash, LayoutHash(Engine().Solve(Job( new[] @@ -57,7 +59,7 @@ public class RectanglesClampZoneTests new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5)) })))); [Theory] - [InlineData("DBF93C1684D85B5EE7EB24C96A84904D2BA0F9A8183631EFC61638CB8B9B935A")] + [InlineData("F848E4C6213888302A9EBAC2B9839879FB1A4B6D919ACB8932FA1639FF3508A9")] public void ZeroClampMultiSheetLayoutIsBitIdenticalToBaseline(string hash) => Assert.Equal(hash, LayoutHash(Engine().Solve(Job( new[] { Part("square", Rectangle(10, 10), 30) }, @@ -280,6 +282,7 @@ public class RectanglesClampZoneTests [Theory] // Captured from the unchanged packer at ab50900 (see progress task-6.2 evidence): // non-inverted clamp spans must keep today's blocks and layouts bit-for-bit. + // KEEP_LARGE_SPACING was re-captured when equal-cost layouts began preferring the larger offcut. [InlineData("KEEP_LARGE_SPACING")] [InlineData("KEEP_BETWEEN_CLAMPS")] [InlineData("KEEP_BETWEEN_SPACED")] @@ -288,7 +291,7 @@ public class RectanglesClampZoneTests { var hash = fixture switch { - "KEEP_LARGE_SPACING" => "F7E4691C085FFB2164CEFFFB9B082B5F271E56E8B8B3D2AE23ECF5646FD40450", + "KEEP_LARGE_SPACING" => "3F390B18E99C6625E06A19295145F01EA85FEB7089DA99ED093E683AC71F7D02", "KEEP_BETWEEN_CLAMPS" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61", "KEEP_BETWEEN_SPACED" => "F09B878211EB7C49649647003410B20191F635E3CD8DED6E03BEA5BD63B17D61", "KEEP_MID_RAIL_SMALL_SPACING" => "97868486E834CA0B1DB110FF717635E38537DB96DF5CDFBB2AFE37E4B1366B71", diff --git a/OpenNest.Engine/Jobs/NestJobCost.cs b/OpenNest.Engine/Jobs/NestJobCost.cs index cd7e7ce5..4abd34ed 100644 --- a/OpenNest.Engine/Jobs/NestJobCost.cs +++ b/OpenNest.Engine/Jobs/NestJobCost.cs @@ -19,6 +19,45 @@ public static class NestJobCost var minimum = job.Options.MinimumSalvageDimension; if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0) return area; + var (left, bottom, right, top) = MaterialEnvelope(job, sheet); + return NetSheetArea(job.Options, sheet.Stock, left, bottom, right, top); + } + + /// + /// Tie-break for layouts of equal cost: the largest full-width/full-length edge offcut, as + /// salvage measures it but whatever the salvage rate. Offcuts must meet a positive + /// MinimumSalvageDimension; with none, any offcut counts. Larger is more compact. An empty + /// sheet has none. Never part of the cost, so it cannot outrank a cheaper layout. + /// + public static double CompactnessOffcut(NestJob job, NestJobPlateResult sheet) + { + if (sheet.Placements.Count == 0) + return 0; + var (left, bottom, right, top) = MaterialEnvelope(job, sheet); + return LargestOffcut(sheet.Stock, left, bottom, right, top, + System.Math.Max(0, job.Options.MinimumSalvageDimension)); + } + + /// from an existing + /// material-only placed-parts envelope. + public static double CompactnessOffcut(NestJobOptions options, NestPlateStock stock, Box partsEnvelope) => + LargestOffcut(stock, partsEnvelope.Left, partsEnvelope.Bottom, partsEnvelope.Right, partsEnvelope.Top, + System.Math.Max(0, options.MinimumSalvageDimension)); + + /// + /// An offcut on a 1e-6 grid, for exact tie-break comparisons: differences below it are + /// envelope rounding, which must not decide a tie differently across platform math libraries. + /// + public static double TieBreakKey(double offcut) => System.Math.Round(offcut, 6); + + /// Whole-job , summed over sheets. + public static double CompactnessOffcut(NestJob job, NestJobResult result) => + RequireFinite(result.Plates.Sum(sheet => CompactnessOffcut(job, sheet))); + + /// Bounds of the sheet's rotated material contours; scribe/etch marks are excluded. + private static (double Left, double Bottom, double Right, double Top) MaterialEnvelope( + NestJob job, NestJobPlateResult sheet) + { var parts = job.Parts.ToDictionary(p => p.Id); var boxes = sheet .Placements.Select(p => @@ -30,8 +69,7 @@ public static class NestJobCost return NestLayoutCheck.MaterialBounds(part); }) .ToList(); - return NetSheetArea(job.Options, sheet.Stock, boxes.Min(b => b.Left), - boxes.Min(b => b.Bottom), boxes.Max(b => b.Right), boxes.Max(b => b.Top)); + return (boxes.Min(b => b.Left), boxes.Min(b => b.Bottom), boxes.Max(b => b.Right), boxes.Max(b => b.Top)); } /// @@ -50,6 +88,12 @@ public static class NestJobCost var minimum = options.MinimumSalvageDimension; if (options.SalvageRate == 0 || minimum <= 0) return area; + return area - options.SalvageRate * LargestOffcut(stock, left, bottom, right, top, minimum); + } + + private static double LargestOffcut( + NestPlateStock stock, double left, double bottom, double right, double top, double minimum) + { var work = stock.WorkArea; var gap = stock.PartSpacing; var candidates = new[] @@ -59,12 +103,11 @@ public static class NestJobCost (left - work.Left - gap, work.Width), (work.Right - right - gap, work.Width), }; - var salvage = candidates + return candidates .Where(c => c.Item1 >= minimum && c.Item2 >= minimum) .Select(c => c.Item1 * c.Item2) .DefaultIfEmpty(0) .Max(); - return area - options.SalvageRate * salvage; } public static bool UsesExplicitCosts(NestJob job) => diff --git a/OpenNest.Engine/NestingEngines/CandidateScore.cs b/OpenNest.Engine/NestingEngines/CandidateScore.cs index 0cedd8e7..c0137f5e 100644 --- a/OpenNest.Engine/NestingEngines/CandidateScore.cs +++ b/OpenNest.Engine/NestingEngines/CandidateScore.cs @@ -5,9 +5,11 @@ using OpenNest.Engine.Jobs; namespace OpenNest.Engine.NestingEngines; /// Ranks whole-job candidate results: a valid layout before an invalid one, then the -/// fewest unplaced parts, then the lowest ; remaining ties keep -/// candidate order. -internal sealed record CandidateScore(NestJobResult Result, bool Valid, int Unplaced, double Cost, int Order, bool Priced) +/// fewest unplaced parts, then the lowest , then the largest +/// (without salvage credit every +/// one-sheet layout costs the same); remaining ties keep candidate order. +internal sealed record CandidateScore( + NestJobResult Result, bool Valid, int Unplaced, double Cost, double Offcut, int Order, bool Priced) { public static CandidateScore Of(NestJob job, NestJobResult result, int order) { @@ -16,7 +18,8 @@ internal sealed record CandidateScore(NestJobResult Result, bool Valid, int Unpl 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 CandidateScore(result, valid, unplaced, cost, order, NestJobCost.UsesExplicitCosts(job)); + var offcut = valid ? NestJobCost.CompactnessOffcut(job, result) : 0; + return new CandidateScore(result, valid, unplaced, cost, offcut, order, NestJobCost.UsesExplicitCosts(job)); } public bool IsBetterThan(CandidateScore other) @@ -30,6 +33,9 @@ internal sealed record CandidateScore(NestJobResult Result, bool Valid, int Unpl 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; + var offcutScale = System.Math.Max(1, System.Math.Max(Offcut, other.Offcut)); + if (System.Math.Abs(Offcut - other.Offcut) > 1e-9 * offcutScale) + return Offcut > other.Offcut; return Order < other.Order; } } diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs index df50dc91..2c728499 100644 --- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs @@ -121,7 +121,8 @@ public sealed class IrregularNestingEngine : INestingEngine using var solver = new Solver(job, types, null, default, WorkBudget, DrawingFor, SheetPacked, BatchSelected, TailReplaced); var unplaced = job.Parts.Sum(p => p.Quantity) - physicalPlan.Sum(s => s.Parts.Count); var plan = new Plan(physicalPlan, physicalPlan.Sum(s => NetCost(job.Options, s)), unplaced, - unplaced == 0 ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound, NestJobCost.UsesExplicitCosts(job)); + unplaced == 0 ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound, NestJobCost.UsesExplicitCosts(job), + job.Options); return BuildResult(job, types, solver.ImproveTail(plan, WorkBudget * 2), null, default); } @@ -170,7 +171,8 @@ public sealed class IrregularNestingEngine : INestingEngine var reason = run.Reason; if (unplaced > 0 && reason == NestJobStopReason.Completed) reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold. - return new Plan(run.Sheets, NestJobCost.RequireFinite(run.Net + unplaced * Penalty), unplaced, reason, NestJobCost.UsesExplicitCosts(job)); + return new Plan(run.Sheets, NestJobCost.RequireFinite(run.Net + unplaced * Penalty), unplaced, reason, + NestJobCost.UsesExplicitCosts(job), job.Options); } /// @@ -326,6 +328,7 @@ public sealed class IrregularNestingEngine : INestingEngine .Select((t, order) => (t.Fill, t.Net, order, Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio))) .OrderBy(t => t.Estimate) .ThenByDescending(t => t.Fill.Parts.Count) + .ThenByDescending(t => NestJobCost.TieBreakKey(Offcut(job.Options, t.Fill))) .ThenBy(t => t.order) .First(); @@ -387,17 +390,29 @@ public sealed class IrregularNestingEngine : INestingEngine return builder.Build(plan.Reason); } - private static double NetCost(NestJobOptions options, SheetFill fill) + private static double NetCost(NestJobOptions options, SheetFill fill) => + fill.Parts.Count == 0 ? NestJobCost.GrossSheetCost(fill.Stock) + : NestJobCost.NetSheetCost(options, fill.Stock, Envelope(fill)); + + /// Equal-cost tie-break: the fill's . + private static double Offcut(NestJobOptions options, SheetFill fill) => + fill.Parts.Count == 0 ? 0 : NestJobCost.CompactnessOffcut(options, fill.Stock, Envelope(fill)); + + private static OpenNest.Geometry.Box Envelope(SheetFill fill) { - if (fill.Parts.Count == 0) return NestJobCost.GrossSheetCost(fill.Stock); var left = fill.Parts.Min(p => p.Left); var bottom = fill.Parts.Min(p => p.Bottom); - return NestJobCost.NetSheetCost(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom, - fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom)); + return 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, bool Priced) + /// Ranked by unplaced parts, cost, sheet count, then compactness offcut (without salvage + /// credit every one-sheet plan costs the same); full ties keep the incumbent. + private sealed record Plan(IReadOnlyList Sheets, double Cost, int Unplaced, NestJobStopReason Reason, + bool Priced, NestJobOptions Options) { + private double Offcut => Sheets.Sum(s => IrregularNestingEngine.Offcut(Options, s)); + public bool IsBetterThan(Plan other) { if (Unplaced != other.Unplaced) @@ -407,7 +422,11 @@ public sealed class IrregularNestingEngine : INestingEngine 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; + if (Sheets.Count != other.Sheets.Count) + return Sheets.Count < other.Sheets.Count; + var offcut = Offcut; + var otherOffcut = other.Offcut; + return offcut - otherOffcut > 1e-9 * System.Math.Max(1, System.Math.Max(offcut, otherOffcut)); } } } diff --git a/OpenNest.Engine/NestingEngines/SheetByStock.cs b/OpenNest.Engine/NestingEngines/SheetByStock.cs index 127bf921..658f4bc6 100644 --- a/OpenNest.Engine/NestingEngines/SheetByStock.cs +++ b/OpenNest.Engine/NestingEngines/SheetByStock.cs @@ -68,7 +68,7 @@ internal static class SheetByStock break; } - var trials = new List<(SheetLayout Layout, double Net)>(); + var trials = new List<(SheetLayout Layout, double Net, double Offcut)>(); if (preferred != null) Try(preferred, trials); if (trials.Count == 0) @@ -119,7 +119,7 @@ internal static class SheetByStock return result.Build(reason); - void Try(NestPlateStock stock, List<(SheetLayout Layout, double Net)> trials) + void Try(NestPlateStock stock, List<(SheetLayout Layout, double Net, double Offcut)> trials) { token.ThrowIfCancellationRequested(); if (stock.Quantity is int available && used[stock.Id] >= available) @@ -128,7 +128,7 @@ internal static class SheetByStock NestJobStage.EvaluatingCandidate, stock.Id, SheetsUsed(result, job), SheetsUsed(result, job), 0)); foreach (var layout in pack(stock, remaining)) if (layout.Poses.Count > 0) - trials.Add((layout, NetCost(job, layout))); + trials.Add((layout, NetCost(job, layout), Offcut(job, layout))); } } @@ -138,19 +138,22 @@ internal static class SheetByStock /// /// Picks the layout with the lowest estimated whole-job cost. Remaining demand is priced at the /// best net-cost-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. + /// competes fairly with a denser partial one. Ties: more material placed, then the larger + /// compactness offcut (without salvage credit every layout on one stock costs the same), then + /// enumeration order. /// private static SheetLayout Choose(IReadOnlyList types, int[] remaining, - IReadOnlyDictionary priority, List<(SheetLayout Layout, double Net)> trials) + IReadOnlyDictionary priority, List<(SheetLayout Layout, double Net, double Offcut)> trials) { var demandArea = types.Sum(t => remaining[t.Index] * t.MaterialArea); var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Layout.MaterialArea, 1e-12)); return trials - .Select((t, order) => (t.Layout, order, + .Select((t, order) => (t.Layout, t.Offcut, order, Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, demandArea - t.Layout.MaterialArea) * bestRatio))) .OrderBy(t => PriorityDebt(types, remaining, priority, t.Layout)) .ThenBy(t => t.Estimate) .ThenByDescending(t => t.Layout.MaterialArea) + .ThenByDescending(t => t.Offcut) .ThenBy(t => t.order) .First() .Layout; @@ -176,4 +179,9 @@ internal static class SheetByStock layout.Envelope is { } envelope ? NestJobCost.NetSheetCost(job.Options, layout.Stock, envelope) : NestJobCost.GrossSheetCost(layout.Stock); + + private static double Offcut(NestJob job, SheetLayout layout) => + layout.Envelope is { } envelope + ? NestJobCost.TieBreakKey(NestJobCost.CompactnessOffcut(job.Options, layout.Stock, envelope)) + : 0; } diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index fc86ff23..84dd99de 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -235,8 +235,10 @@ With prices, gross sheet cost is the supplied value. Salvage is still computed f the largest qualifying material-edge offcut; net cost is `Cost * (NetSheetArea / SheetArea)`. Set desktop salvage rate to zero for purchase-price comparisons. Entering prices preserves the selected salvage settings. MCP and API -currently disable salvage. Without prices, scores retain their existing area units -and tie ordering; fixed strategies retain their legacy no-salvage area tie-break. +currently disable salvage. Without prices, scores retain their existing area units; +whole-job engines break exact cost ties by the compactness offcut (see +[cost basis](nesting-engines.md#cost-basis-and-heuristic-limits)), and fixed strategies retain +their legacy no-salvage area tie-break. Desktop reports gross cost, estimated salvage credit and net score. MCP reports `basis`, each stock's used physical count, supplied cost and gross subtotal, plus diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index f7c92bd6..918b81cc 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -11,7 +11,7 @@ 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, Rectangles and Circles, checks each layout with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost; ties keep the earlier engine | +| Default | Any job; used when no engine is named | Runs Irregular, Rectangles and Circles, checks each layout with the layout check and keeps the best: valid first, then fewest unplaced parts, then lowest salvage-credited cost, then the largest compactness offcut; remaining ties keep the earlier engine | | 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 | | Circles | Round parts: blanks, washers, flanges, gears by their tip circle | Each part packed as its minimum enclosing circle at centers tangent to two of walls, placed discs and clamp keep-outs, with hexagonal and square lattice seeds; bids only when every part is round; stock chosen as Rectangles does, plus whole-job runs preferring each stock and a tail re-plan | | 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 | @@ -414,3 +414,11 @@ consolidation. Default compares valid equally fulfilled results by generic job s Fixed Fill/Strip/remnant strategies rank priority fulfillment first, then net cost for priced stock (gross area for legacy unpriced stock), then existing stable ties. None guarantees globally optimal purchasing or explores every combination of patterns. + +Without salvage credit, which desktop Auto Nest applies only when offering several plate +sizes, every complete layout on one stock costs the same. Default, Rectangles, Circles and +Irregular then break exact cost ties by `NestJobCost.CompactnessOffcut`: the largest +full-width or full-length edge offcut, measured as salvage measures it but whatever the +salvage rate (it honors a positive minimum salvage dimension; with none, any offcut counts). +Larger wins, summed over a job's sheets. It is never part of the cost, so it cannot outrank a +cheaper layout or change a reported cost.