From 7ce53008f17eebce8f78655e979276d0107ccc61 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 30 Sep 2026 22:58:41 -0400 Subject: [PATCH] fix(rectangles): place parts within the layout check's work-area slack Parts drawn a few millionths over their sheet's work area (for example a 36.125006 panel on a 36.125 sheet with no edge spacing) pass NestLayoutCheck, which allows Tolerance.Epsilon of overhang, but the Rectangles engine refused them and left them unplaced. The engine now allows 90% of that slack: a box that exceeds the sheet by no more than the allowance packs as exactly the sheet's size, so it spans the axis and the overhang lands only past the edge. The check's bounds slack is named NestTolerances.WorkAreaSlack (same value, no behavior change) so the engine and the test layout assertion share it. Rectangle-lane benchmark (91 strict + 77 boxable jobs): every layout valid; 4 jobs that left panels unplaced now place them (strict complete 86 -> 88, boxable complete 74 -> 75, one more boxable job places 2 more parts); no other job changed. --- .../NestingEngines/LayoutAssert.cs | 5 ++-- .../RectanglesNestingEngineTests.cs | 30 +++++++++++++++++++ OpenNest.Engine/Jobs/NestLayoutCheck.cs | 8 ++--- OpenNest.Engine/Jobs/NestTolerances.cs | 6 ++++ .../Rectangles/RectanglesNestingEngine.cs | 4 +-- .../NestingEngines/Rectangles/SheetPacker.cs | 21 ++++++++++++- 6 files changed, 65 insertions(+), 9 deletions(-) diff --git a/OpenNest.Engine.Tests/NestingEngines/LayoutAssert.cs b/OpenNest.Engine.Tests/NestingEngines/LayoutAssert.cs index 12109f0..53beb77 100644 --- a/OpenNest.Engine.Tests/NestingEngines/LayoutAssert.cs +++ b/OpenNest.Engine.Tests/NestingEngines/LayoutAssert.cs @@ -27,8 +27,9 @@ public static class LayoutAssert foreach (var entity in geometry) { entity.Rotate(pose.Rotation); entity.Offset(pose.X, pose.Y); } var b = (L: geometry.Min(e => e.Left), B: geometry.Min(e => e.Bottom), R: geometry.Max(e => e.Right), T: geometry.Max(e => e.Top)); - Assert.True(b.L >= work.Left - 1e-7 && b.B >= work.Bottom - 1e-7 - && b.R <= work.Right + 1e-7 && b.T <= work.Top + 1e-7); + var slack = NestTolerances.WorkAreaSlack; + Assert.True(b.L >= work.Left - slack && b.B >= work.Bottom - slack + && b.R <= work.Right + slack && b.T <= work.Top + slack); } } foreach (var part in job.Parts) diff --git a/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs b/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs index ae3da14..495db33 100644 --- a/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs +++ b/OpenNest.Engine.Tests/NestingEngines/RectanglesNestingEngineTests.cs @@ -152,6 +152,36 @@ public class RectanglesNestingEngineTests LayoutAssert.Valid(job, result); Assert.Equal(NestJobStatus.Complete, result.Status); } + + [Fact] + public void PartAFewMillionthsWiderThanTheWorkAreaIsPlaced() + { + // Exported panels are often drawn a few millionths over their sheet's work area; the + // layout check accepts that overhang, so the engine must place them. The chamfer keeps the + // material area inside the sheet's area budget, as a real panel's corner radii do. + var panel = Polyline((0, 0), (36.125006, 0), (36.125006, 74), (35.125006, 75), (0, 75)); + var job = Job(new[] { Part("panel", panel, 1) }, + new[] { Stock("s", 75, 36.125, spacing: 0.25, quantity: 1) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Equal(NestJobStatus.Complete, result.Status); + } + + [Fact] + public void PartBeyondTheOverhangAllowanceStaysUnplaced() + { + var panel = Polyline((0, 0), (36.12502, 0), (36.12502, 74), (35.12502, 75), (0, 75)); + var job = Job(new[] { Part("panel", panel, 1) }, + new[] { Stock("s", 75, 36.125, spacing: 0.25, quantity: 1) }); + + var result = new RectanglesNestingEngine().Solve(job); + + LayoutAssert.Valid(job, result); + Assert.Empty(result.Plates); + Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced); + } } public sealed class RectanglesContractTests : EngineContractTests { } diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index 8481c36..f20e245 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -291,10 +291,10 @@ public static class NestLayoutCheck { var bb = MaterialBounds(part); - var outLeft = bb.Left < workArea.X - Tolerance.Epsilon; - var outBottom = bb.Bottom < workArea.Y - Tolerance.Epsilon; - var outRight = bb.Right > workArea.Right + Tolerance.Epsilon; - var outTop = bb.Top > workArea.Top + Tolerance.Epsilon; + var outLeft = bb.Left < workArea.X - NestTolerances.WorkAreaSlack; + var outBottom = bb.Bottom < workArea.Y - NestTolerances.WorkAreaSlack; + var outRight = bb.Right > workArea.Right + NestTolerances.WorkAreaSlack; + var outTop = bb.Top > workArea.Top + NestTolerances.WorkAreaSlack; if (outLeft || outBottom || outRight || outTop) { diff --git a/OpenNest.Engine/Jobs/NestTolerances.cs b/OpenNest.Engine/Jobs/NestTolerances.cs index 9752137..9ce222e 100644 --- a/OpenNest.Engine/Jobs/NestTolerances.cs +++ b/OpenNest.Engine/Jobs/NestTolerances.cs @@ -1,5 +1,6 @@ using System; using OpenNest.Geometry; +using OpenNest.Math; namespace OpenNest.Engine.Jobs; @@ -18,6 +19,11 @@ public static class NestTolerances /// with margin and is far below anything a cutting machine can resolve. public const double SpacingSlack = 0.0005; + /// How far material may extend past the work area and still pass the layout check's + /// bounds test. Matches the general geometry tolerance, so a part drawn a few millionths + /// larger than its sheet's work area (as exported dimensions often are) is still accepted. + public const double WorkAreaSlack = Tolerance.Epsilon; + /// Clipper decimal precision (a 1e-4 coordinate grid). public const int ClipperPrecision = ClipperBridge.Precision; diff --git a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs index cec1318..510fc44 100644 --- a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs +++ b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs @@ -143,8 +143,8 @@ public sealed class RectanglesNestingEngine : INestingEngine private static bool FitsStock(NestPlateStock stock, BoxOrientation o) { var work = stock.WorkArea; - return o.Width <= work.Right - work.Left + MaxRectsSheet.Eps - && o.Height <= work.Top - work.Bottom + MaxRectsSheet.Eps; + return o.Width <= work.Right - work.Left + SheetPacker.OverhangAllowance + && o.Height <= work.Top - work.Bottom + SheetPacker.OverhangAllowance; } } diff --git a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs index 79858c6..5f56cd3 100644 --- a/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs +++ b/OpenNest.Engine/NestingEngines/Rectangles/SheetPacker.cs @@ -34,6 +34,21 @@ internal sealed record SheetPlan( /// internal static class SheetPacker { + /// + /// How far a box may overhang the work area. Kept just inside the layout check's + /// so floating-point rounding cannot push an + /// accepted box over it. + /// + public const double OverhangAllowance = NestTolerances.WorkAreaSlack * 0.9; + + /// + /// Packing size along one axis: a box that exceeds the sheet by no more than + /// packs as exactly the sheet's size. It then spans the whole + /// axis, so it has no neighbour there and the overhang lands only past the work-area edge. + /// + public static double PackSize(double size, double sheet) => + size > sheet && size <= sheet + OverhangAllowance ? sheet : size; + public static SheetPlan Pack( IReadOnlyList types, IReadOnlyList remaining, NestPlateStock stock, FitRule rule, PickMode mode, CancellationToken token) @@ -43,7 +58,11 @@ internal static class SheetPacker var sheet = new MaxRectsSheet(work.Right - work.Left + s, work.Top - work.Bottom + s); var packTypes = types .Select(t => new PackType( - t.Priority, t.Orientations.Select(o => (o.Width + s, o.Height + s)).ToList(), t.BoxArea)) + t.Priority, + t.Orientations + .Select(o => (PackSize(o.Width + s, sheet.Width), PackSize(o.Height + s, sheet.Height))) + .ToList(), + t.BoxArea)) .ToList(); var placed = MaxRectsPacker .Pack(packTypes, remaining.ToArray(), sheet, rule, mode, token)