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.
This commit is contained in:
aj
2026-09-30 22:58:41 -04:00
parent 4b6e131cec
commit 7ce53008f1
6 changed files with 65 additions and 9 deletions
@@ -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)
@@ -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<RectanglesNestingEngine> { }
+4 -4
View File
@@ -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)
{
+6
View File
@@ -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.</summary>
public const double SpacingSlack = 0.0005;
/// <summary>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.</summary>
public const double WorkAreaSlack = Tolerance.Epsilon;
/// <summary>Clipper decimal precision (a 1e-4 coordinate grid).</summary>
public const int ClipperPrecision = ClipperBridge.Precision;
@@ -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;
}
}
@@ -34,6 +34,21 @@ internal sealed record SheetPlan(
/// </summary>
internal static class SheetPacker
{
/// <summary>
/// How far a box may overhang the work area. Kept just inside the layout check's
/// <see cref="NestTolerances.WorkAreaSlack"/> so floating-point rounding cannot push an
/// accepted box over it.
/// </summary>
public const double OverhangAllowance = NestTolerances.WorkAreaSlack * 0.9;
/// <summary>
/// Packing size along one axis: a box that exceeds the sheet by no more than
/// <see cref="OverhangAllowance"/> 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.
/// </summary>
public static double PackSize(double size, double sheet) =>
size > sheet && size <= sheet + OverhangAllowance ? sheet : size;
public static SheetPlan Pack(
IReadOnlyList<BoxType> types, IReadOnlyList<int> 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)