diff --git a/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs b/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs index 9ca9baa2..5a002aa9 100644 --- a/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/RectanglesClampZoneTests.cs @@ -36,8 +36,10 @@ public class RectanglesClampZoneTests [Theory] // 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")] + // re-captured when equal-cost sheet layouts began preferring the larger free offcut, then + // when the finishing sheet began squeezing into a narrower bin (twelve parts now reach + // 20.25 along X instead of spreading over the sheet). + [InlineData("578D350691FF80C768BD4AE245F03863885669B7E9AB86A9CDCB0302B1F1DD01")] public void ZeroClampLayoutIsBitIdenticalToBaseline(string hash) => Assert.Equal(hash, LayoutHash(Engine().Solve(Job( new[] { Part("rect", Rectangle(10, 5), 12) }, @@ -65,6 +67,13 @@ public class RectanglesClampZoneTests new[] { Part("square", Rectangle(10, 10), 30) }, new[] { Stock("sheet", 25, 45, spacing: 0.25) })))); + private static string[] Poses(NestJobResult result) => result.Plates + .SelectMany(sheet => sheet.Placements.Select(p => string.Join('|', sheet.PlateIndex, sheet.StockId, p.PartId, + p.X.ToString("R", CultureInfo.InvariantCulture), p.Y.ToString("R", CultureInfo.InvariantCulture), + p.Rotation.ToString("R", CultureInfo.InvariantCulture)))) + .Order(StringComparer.Ordinal) + .ToArray(); + // ---- clamp avoidance --------------------------------------------------------------- [Fact] @@ -149,7 +158,10 @@ public class RectanglesClampZoneTests var plain = Engine().Solve(jobPlain); LayoutAssert.Valid(jobClamped, clamped); - Assert.Equal(LayoutHash(plain), LayoutHash(clamped)); + // No part may move. The zone splits the packer's free list, so the finishing-sheet + // squeeze can reach the same poses through a different rule and list them in another + // order; compare the poses, not their order. + Assert.Equal(Poses(plain), Poses(clamped)); } // ---- large part spacing vs shallow clamp spans (PM d86d22c9, decision D11) -------- diff --git a/OpenNest.Engine.Tests/NestingEngines/RectanglesSqueezeTests.cs b/OpenNest.Engine.Tests/NestingEngines/RectanglesSqueezeTests.cs new file mode 100644 index 00000000..63f49604 --- /dev/null +++ b/OpenNest.Engine.Tests/NestingEngines/RectanglesSqueezeTests.cs @@ -0,0 +1,69 @@ +using OpenNest.Converters; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.NestingEngines.Rectangles; +using OpenNest.Geometry; +using static OpenNest.Engine.Tests.NestingEngines.JobBuilder; +using static OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.NestingEngines; + +/// +/// The sheet that finishes a job is re-packed into a shorter or narrower bin, so the drop it +/// leaves is as large as the packer can make it. +/// +public class RectanglesSqueezeTests +{ + /// + /// N26-2A5: 14 door stops (4.168 x 1.5) and 14 lock plates (2 x 2) on a 26 x 26 sheet with a + /// front clamp. Every fit rule on the whole sheet stopped at 8.0 high; the shop's hand layout + /// holds all 28 under 7.5 (three rows of four stops, two stops on end over the clamp, a 2 x 2 + /// block of plates, then a row of plates across the top), leaving an 18" drop. + /// + [Fact] + public void FinishingSheetPacksIntoTheShortestStripItFinds() + { + var clamp = new ClampZone("clamp-1", new Box(19.25, 0, 1.5, 0.625)); + var job = Job( + [ + Part("door stop", Rectangle(4.168108, 1.5), 14), + Part("lock plate", Rectangle(2, 2), 14), + ], + [Stock("sheet", 26, 26, spacing: 0.25, edge: new Spacing(0.25, 0.25, 0.25, 0.25), clampZones: [clamp])]); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Single(result.Plates); + Assert.True(Top(job, result.Plates[0]) <= 7.5 + 1e-9, $"layout top {Top(job, result.Plates[0])}"); + } + + [Fact] + public void SheetsThatDoNotFinishTheJobAreNotSqueezed() + { + // Four 9 x 9 squares fill each 20 x 20 sheet; only the last sheet could be squeezed, and + // it repeats the full layout, so every sheet keeps one pass of the twelve rule/mode packs. + var job = Job([Rectangle("a", 9, 9, 40)], [Stock("s", 20, 20, spacing: 0.2)]); + var calls = 0; + + var result = new RectanglesNestingEngine { SheetPacked = _ => calls++ }.Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(12, calls); + } + + private static double Top(NestJob job, NestJobPlateResult sheet) => + sheet.Placements.Max(pose => + { + var part = job.Parts.Single(p => p.Id == pose.PartId); + 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); + } + return geometry.Max(e => e.Top); + }); +} diff --git a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs index 3c71f8d6..76479a7e 100644 --- a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs @@ -64,14 +64,29 @@ public sealed class RectanglesNestingEngine : INestingEngine { // Both private MaxRects modes return only after their normal demand/no-fit search; // neither returns partial/budget-limited output. This is not a geometric impossibility proof. + var plans = PackAll(stock, left, null, null); + foreach (var plan in plans) + yield return Layout(plan); + // A sheet that finishes the job is also tried squeezed into a shorter or narrower bin. + foreach (var plan in SheetSqueeze.Run(types, left, stock, plans, + (width, height) => PackAll(stock, left, width, height), token)) + yield return Layout(plan); + } + + IReadOnlyList PackAll(NestPlateStock stock, IReadOnlyList left, double? width, double? height) + { + var plans = new List(Modes.Length * Rules.Length); foreach (var mode in Modes) foreach (var rule in Rules) { - var plan = SheetPacker.Pack(types, left, stock, rule, mode, token); + plans.Add(SheetPacker.Pack(types, left, stock, rule, mode, token, width, height)); SheetPacked?.Invoke(mode); - yield return new SheetLayout(stock, plan.Poses().ToArray(), plan.MaterialArea, plan.Envelope); } + return plans; } + + static SheetLayout Layout(SheetPlan plan) => + new(plan.Stock, plan.Poses().ToArray(), plan.MaterialArea, plan.Envelope); } private static bool FitsStock(NestPlateStock stock, BoxOrientation o) diff --git a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs index 2c746498..4408ea1c 100644 --- a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs +++ b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs @@ -49,19 +49,36 @@ internal static class SheetPacker public static double PackSize(double size, double sheet) => size > sheet && size <= sheet + OverhangAllowance ? sheet : size; + /// Packing bin size for a stock: its work area grown by one part spacing per axis. + public static (double Width, double Height) BinSize(NestPlateStock stock) + { + var work = stock.WorkArea; + return (work.Right - work.Left + stock.PartSpacing, work.Top - work.Bottom + stock.PartSpacing); + } + + /// When set, a squeeze limit: bin-local packing width (box plus its + /// spacing tail) no box may pass, so material ends one part spacing short of it. + /// The same limit along Y. public static SheetPlan Pack( IReadOnlyList types, IReadOnlyList remaining, NestPlateStock stock, - FitRule rule, PickMode mode, CancellationToken token) + FitRule rule, PickMode mode, CancellationToken token, + double? maxBinWidth = null, double? maxBinHeight = null) { var work = stock.WorkArea; var s = stock.PartSpacing; - var sheet = new MaxRectsSheet(work.Right - work.Left + s, work.Top - work.Bottom + s); - BlockClamps(sheet, stock, work, s); + var (binWidth, binHeight) = BinSize(stock); + // A squeeze packs a smaller bin from the same corner, exactly as on a smaller sheet (the + // contact rule scores its edges too); the overhang allowance and clamp side choice still + // measure the real sheet. + var sheet = new MaxRectsSheet( + maxBinWidth is double w && w < binWidth ? w : binWidth, + maxBinHeight is double h && h < binHeight ? h : binHeight); + BlockClamps(sheet, stock, work, s, binWidth, binHeight); var packTypes = types .Select(t => new PackType( t.Priority, t.Orientations - .Select(o => (PackSize(o.Width + s, sheet.Width), PackSize(o.Height + s, sheet.Height))) + .Select(o => (PackSize(o.Width + s, binWidth), PackSize(o.Height + s, binHeight))) .ToList(), t.BoxArea)) .ToList(); @@ -104,7 +121,8 @@ internal static class SheetPacker /// exactly on the centerline with material inside the clamp. /// Empty zone lists change nothing: the free list evolves bit-identically (C2). /// - private static void BlockClamps(MaxRectsSheet sheet, NestPlateStock stock, Box work, double s) + private static void BlockClamps( + MaxRectsSheet sheet, NestPlateStock stock, Box work, double s, double binWidth, double binHeight) { if (stock.ClampZones.Count == 0) return; @@ -121,7 +139,7 @@ internal static class SheetPacker { var center = x0((b.Left + b.Right) * 0.5); // D11: the interior-facing clamp edge (toward the farther sheet edge). - var line = center < sheet.Width * 0.5 + var line = center < binWidth * 0.5 ? x0(b.Right) + clearance : x0(b.Left) - clearance + s; left = line; @@ -132,7 +150,7 @@ internal static class SheetPacker if (top <= bottom) { var center = y0((b.Bottom + b.Top) * 0.5); - var line = center < sheet.Height * 0.5 + var line = center < binHeight * 0.5 ? y0(b.Top) + clearance : y0(b.Bottom) - clearance + s; bottom = line; diff --git a/OpenNest.Engine/NestingEngines/Rectangles/SheetSqueeze.cs b/OpenNest.Engine/NestingEngines/Rectangles/SheetSqueeze.cs new file mode 100644 index 00000000..86c8ff68 --- /dev/null +++ b/OpenNest.Engine/NestingEngines/Rectangles/SheetSqueeze.cs @@ -0,0 +1,215 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Engine.Jobs; + +namespace OpenNest.Engine.NestingEngines.Rectangles; + +/// +/// Squeezes the sheet that finishes the job. The fit rules pack into the whole sheet, so when the +/// remaining demand leaves room they spread it out and rarely find the shortest strip that holds +/// it: on N26-2A5 (14 door stops and 14 lock plates on a 26 x 26 clamped sheet) every rule +/// stopped at 8.0 high while a 7.5 strip holds all 28. When some packing places all the remaining +/// demand, this re-packs it into a bin cut shorter (then, separately, narrower), running every rule +/// and mode at each probe, and returns the tightest complete layout per axis when it beats the +/// unsqueezed ones; the stock loop then picks by cost and edge drop as usual. +/// +/// Whether a greedy packing fits is far from monotonic in the bin size (that job packs at 7.5 but +/// not at 7.62 or 7.74), and a tight layout ends where a stack of spaced part sides ends. So the +/// probes are those stack heights: sums of the parts' spaced sides along the axis, starting from +/// the bin edge or the far edge of a clamp keep-out the layout reaches. They walk down from the +/// best extent so far, each step spread over what is left of the range for the probes left, and a +/// failed probe never raises the floor. When there are too many sums to list, the walk steps +/// through the size range instead. A probe that places everything moves the walk down to that +/// layout's own extent, so the answer never keeps a probe's slack. +/// Deterministic, and bounded by a work budget over demand size: large jobs skip it entirely. +/// +internal static class SheetSqueeze +{ + /// Most probes per axis. + internal const int MaxProbes = 12; + + /// Box placements the squeeze may spend: probes x variants x demand, both axes. + internal const long PlacementBudget = 50_000; + + /// Most stack heights listed before the walk falls back to stepping the size range. + private const int MaxStackHeights = 2048; + + /// A size-range walk stops once less than this fraction of the bin is left to try, + /// and a squeezed layout must beat the whole-sheet ones by at least as much. + private const double Resolution = 1e-3; + + /// + /// Probes sit this far past a stack height. MaxRects already lets a box overrun by its Eps + /// (1e-9), which covers a stack's float rounding; a margin past that Eps would hide the bin's + /// top edge from the contact-point rule, which scores edge contact within Eps. + /// + private const double ProbeMargin = 1e-10; + + /// The unsqueezed layouts of on this stock. + /// Every rule and mode packed under a bin limit (width, height). + public static IEnumerable Run( + IReadOnlyList types, IReadOnlyList remaining, NestPlateStock stock, + IReadOnlyList plans, Func> packAll, + CancellationToken token) + { + var demand = remaining.Sum(); + bool Complete(SheetPlan p) => p.Parts.Count == demand && p.Envelope != null; + var complete = plans.Where(Complete).ToList(); + if (demand == 0 || complete.Count == 0) + yield break; + + var probes = (int)System.Math.Min(MaxProbes, PlacementBudget / (2L * demand * plans.Count)); + if (probes == 0) + yield break; + + var work = stock.WorkArea; + var s = stock.PartSpacing; + var (binWidth, binHeight) = SheetPacker.BinSize(stock); + + foreach (var vertical in new[] { true, false }) + { + double Extent(SheetPlan p) => vertical ? p.Envelope!.Top - work.Bottom : p.Envelope!.Right - work.Left; + + var bin = vertical ? binHeight : binWidth; + var across = vertical ? binWidth : binHeight; + var baseline = complete.Min(Extent); + var hi = baseline + s; + var lo = LowerBound(types, remaining, across, s, vertical); + // How far across the squeezed axis the tightest whole-sheet layout reaches: only + // clamps within it can hold a stack, so a zone the demand never reaches changes nothing. + var widest = complete.Where(p => Extent(p) == baseline).Max(p => vertical + ? p.Envelope!.Right - work.Left + : p.Envelope!.Top - work.Bottom); + var heights = StackHeights(types, remaining, stock, across, vertical, hi, widest); + SheetPlan? tightest = null; + // Probes walk down from the best extent so far, each spread over what is left of the + // range for the probes left. A failed probe only moves the walk below it: a smaller bin + // may still fit, so it never raises the floor. + var cursor = hi; + for (var probe = 0; probe < probes; probe++) + { + token.ThrowIfCancellationRequested(); + var target = cursor - (cursor - lo) / (probes - probe); + double limit; + if (heights != null) + { + // The highest stack height at or under the target, else the next one down. + var next = heights.LastOrDefault(h => h > lo && h <= target + ProbeMargin); + if (next == 0) + next = heights.LastOrDefault(h => h > lo && h < cursor - ProbeMargin); + if (next == 0) + break; + limit = next + ProbeMargin; + } + else if (cursor - lo > Resolution * bin) + limit = target; + else + break; + + var fits = packAll(vertical ? null : limit, vertical ? limit : null).Where(Complete).ToList(); + if (fits.Count == 0) + { + cursor = limit - ProbeMargin; + continue; + } + // First of the tightest keeps enumeration order as the tie-break. + var best = fits.Aggregate((a, b) => Extent(b) < Extent(a) ? b : a); + tightest = best; + cursor = System.Math.Min(limit, Extent(best) + s) - ProbeMargin; + } + + if (tightest != null && Extent(tightest) < baseline - Resolution * bin) + yield return tightest; + } + } + + /// + /// A bin extent along the squeezed axis below which the demand cannot fit: every part needs its + /// shortest fitting side plus spacing, and the boxes' spaced area must fit across the bin. + /// Clamps only add to it, so ignoring them keeps the bound valid. + /// + private static double LowerBound( + IReadOnlyList types, IReadOnlyList remaining, double across, double s, bool vertical) + { + double fit = 0, area = 0; + foreach (var t in types) + { + if (remaining[t.Index] == 0 || t.Orientations.Count == 0) + continue; + var sizes = Sizes(t, s, vertical).ToList(); + var fitting = sizes.Where(z => z.Across <= across + SheetPacker.OverhangAllowance).ToList(); + if (fitting.Count > 0) + fit = System.Math.Max(fit, fitting.Min(z => z.Along)); + area += remaining[t.Index] * sizes.Min(z => z.Along * z.Across); + } + return System.Math.Max(fit, area / across); + } + + /// + /// Every bin extent below that a stack of spaced part sides reaches from + /// the bin edge or the far edge of a clamp keep-out that starts within + /// across the axis, sorted; null when there are more than + /// . Stacks ignore part counts, so some heights are unreachable; + /// they cost a probe each, never a missed layout. + /// + private static List? StackHeights( + IReadOnlyList types, IReadOnlyList remaining, NestPlateStock stock, + double across, bool vertical, double below, double reach) + { + var s = stock.PartSpacing; + var sides = types + .Where(t => remaining[t.Index] > 0) + .SelectMany(t => Sizes(t, s, vertical)) + .Where(z => z.Across <= across + SheetPacker.OverhangAllowance && z.Along > 0) + .Select(z => z.Along) + .Distinct() + .ToArray(); + + var work = stock.WorkArea; + var bases = new List { 0 }; + foreach (var zone in stock.ClampZones) + { + var edge = (vertical ? zone.Bounds.Top - work.Bottom : zone.Bounds.Right - work.Left) + + stock.ClampClearance; + var near = (vertical ? zone.Bounds.Left - work.Left : zone.Bounds.Bottom - work.Bottom) + - stock.ClampClearance; + if (edge > 0 && edge < below && near < reach) + bases.Add(edge); + } + + // Keys on a 1e-6 grid merge sums that differ only by float rounding; each keeps its + // largest float value so a probe just past it holds every stack it stands for. + var seen = new Dictionary(); + var queue = new Queue(bases); + while (queue.Count > 0) + { + var v = queue.Dequeue(); + foreach (var side in sides) + { + var w = v + side; + if (w >= below - ProbeMargin) + continue; + var key = System.Math.Round(w, 6); + if (seen.TryGetValue(key, out var kept)) + { + if (w > kept) + seen[key] = w; + continue; + } + if (seen.Count >= MaxStackHeights) + return null; + seen.Add(key, w); + queue.Enqueue(w); + } + } + return seen.Values.OrderBy(h => h).ToList(); + } + + private static IEnumerable<(double Along, double Across)> Sizes(BoxType type, double s, bool vertical) => + type.Orientations.Select(o => vertical + ? (o.Height + s, o.Width + s) + : (o.Width + s, o.Height + s)); +} diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index d5a28f1a..20acfc42 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -12,7 +12,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, then the largest compactness offcut; remaining ties keep the engine listed first here | -| 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 | +| 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; the sheet that finishes the job is also squeezed into a shorter or narrower bin; 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, then grouped variants that pack like parts together, then a sheet-reduction pass that packs edge parts first and turns a key part by its contacts; under a time limit it keeps trying seeded variants until the limit | | FlatBar | Flat bar: parts about as wide as the bar | Each stock read as a bar and cut in one row: the CutList cut-list packer and a best-fill search on part lengths less their self-nesting gain, then a slide-left pass that nests shaped ends and refills freed length; stock chosen bar by bar by whole-plan cost | @@ -56,6 +56,26 @@ Rectangles places irregular parts validly, but only as their bounding boxes; it notch or hole. Box sides account for how the layout check flattens arcs, so round-edged parts stay valid at box contact. +#### Squeezing the last sheet + +The fit rules pack into the whole sheet, so when the last sheet has room to spare they spread the +remaining parts out and rarely find the shortest strip that holds them. On N26-2A5 (14 door stops +and 14 lock plates on a clamped 26 x 26 sheet) every rule stopped 8.0 high, while the hand layout +holds all 28 under 7.5 and leaves a half-inch-deeper drop. So when some rule places all the +remaining demand on a sheet, Rectangles packs that demand again into a bin cut shorter, and +separately narrower, running every rule and mode at each size. It keeps the tightest complete +layout along each axis that beats the whole-sheet ones, and the stock loop then picks among all of +them by cost and offcut as before (`SheetSqueeze`). + +Whether a greedy packing fits is far from monotonic in the bin size (N26-2A5 packs at 7.5 but not +at 7.62 or 7.74), so the sizes probed are stack heights, not halves of a range: sums of the parts' +spaced sides along the axis, starting at the bin edge or at the far side of a clamp the layout +reaches. The probes walk down from the best extent so far, spread over the probes left, and a probe +that fails never raises the floor. With more than 2048 stack heights the walk steps through the +size range instead. Each probe repacks every rule and mode, so the squeeze is capped by a count of +box placements (at most 12 probes per axis and 50,000 placements in all): a sheet finishing more +than about 2,000 parts is not squeezed. Sheets that do not finish the job are never squeezed. + ### Best plan so far Whole-job engines report each better whole-job plan as `NestJobStage.PlanImproved` with the plan