mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-14 18:21:55 -04:00
feat(rectangles): avoid clamp zones in sheet packing
Block each clamp's keep-out into the max-rects free list before any
packing: same split logic as placements, not a used item (no contact
score, no material). The blocked rectangle accounts for the inflated
item model exactly - material keeps the bottom-edge clearance from the
clamp without paying an extra part-spacing tax; degenerate spans
collapse to the zone centerline (conservative, never permissive).
Zero-clamp sheets skip blocking entirely: three fixture layout hashes
(rectangle grid, mixed shapes, multi-sheet overflow), captured from the
unchanged packer at a3f1124, stay bit-identical (C2). With clamps:
placements never intrude, demand lands between two clamps when it fits,
a full-width front rail makes an otherwise feasible job Incomplete, and
a far-away zone leaves the layout hash equal to no-clamps.
Rectangles lane only; Irregular parity is Slice 2.
This commit is contained in:
1 parent
dc87911b2f
commit
de9a087027
4 files changed
+211
-3
No files matched your search
@@ -16,8 +16,9 @@ public static class JobBuilder
|
||||
/// <param name="width">Y extent.</param>
|
||||
/// <param name="length">X extent.</param>
|
||||
public static NestPlateStock Stock(string id, double width, double length, double spacing = 0,
|
||||
Spacing edge = default, int quadrant = 1, int? quantity = null) =>
|
||||
new(id, new Size(width, length), quantity, spacing, edge, quadrant);
|
||||
Spacing edge = default, int quadrant = 1, int? quantity = null,
|
||||
ClampZone[]? clampZones = null) =>
|
||||
new(id, new Size(width, length), quantity, spacing, edge, quadrant, null, clampZones);
|
||||
|
||||
public static NestJobPart Rectangle(string id, double w, double h, int count,
|
||||
RotationPolicy? rotation = null, double x = 0, double y = 0) =>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
|
||||
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.NestingEngines;
|
||||
|
||||
/// <summary>
|
||||
/// The Rectangles lane treats clamp zones as hard obstacles: placements never enter
|
||||
/// the keep-out, demand uses the material between clamps, and layouts with zero
|
||||
/// clamps stay bit-identical to the pre-clamp baseline (C2). Per the plan's Slice 1
|
||||
/// gate these tests name the Rectangles engine directly: the Irregular lane gets
|
||||
/// clamp parity in Slice 2, so until then the Default router is not asserted to
|
||||
/// satisfy C1 on clamp fixtures.
|
||||
/// </summary>
|
||||
public class RectanglesClampZoneTests
|
||||
{
|
||||
private static OpenNest.Engine.NestingEngines.Rectangles.RectanglesNestingEngine Engine() => new();
|
||||
|
||||
// ---- C2 characterization: zero-clamp layouts must not shift by a single bit -------
|
||||
|
||||
private static string LayoutHash(NestJobResult result)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var sheet in result.Plates)
|
||||
foreach (var p in sheet.Placements)
|
||||
sb.Append(sheet.PlateIndex).Append('|').Append(sheet.StockId).Append('|')
|
||||
.Append(p.PartId).Append('|')
|
||||
.Append(p.X.ToString("R", CultureInfo.InvariantCulture)).Append('|')
|
||||
.Append(p.Y.ToString("R", CultureInfo.InvariantCulture)).Append('|')
|
||||
.Append(p.Rotation.ToString("R", CultureInfo.InvariantCulture))
|
||||
.Append('\n');
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sb.ToString())));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Pre-clamp baseline hashes, captured from the unchanged packer at commit a3f1124.
|
||||
[InlineData("1FA61E933D2D0D6F1E3336FCEBBB21CEB7E7D1533FDB253F9D48E947217575CE")]
|
||||
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]
|
||||
[InlineData("9B343B6C5CF2CE8753F2E04ACB0A9C4D5921F4ADC15AA2586AF466769C0F019B")]
|
||||
public void ZeroClampMixedShapeLayoutIsBitIdenticalToBaseline(string hash) =>
|
||||
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
|
||||
new[]
|
||||
{
|
||||
Part("disc", Disc(3), 10),
|
||||
Part("ell", LShape(12, 8, 4), 10),
|
||||
Part("tri", Triangle(9, 6), 10),
|
||||
},
|
||||
new[] { Stock("sheet", 40, 60, spacing: 0.5, edge: new Spacing(0.5, 0.5, 0.5, 0.5)) }))));
|
||||
|
||||
[Theory]
|
||||
[InlineData("DBF93C1684D85B5EE7EB24C96A84904D2BA0F9A8183631EFC61638CB8B9B935A")]
|
||||
public void ZeroClampMultiSheetLayoutIsBitIdenticalToBaseline(string hash) =>
|
||||
Assert.Equal(hash, LayoutHash(Engine().Solve(Job(
|
||||
new[] { Part("square", Rectangle(10, 10), 30) },
|
||||
new[] { Stock("sheet", 25, 45, spacing: 0.25) }))));
|
||||
|
||||
// ---- clamp avoidance ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void PartsNeverTouchAClampZone()
|
||||
{
|
||||
var clamp = new ClampZone("front-center", new Box(19.25, 0, 1.5, 0.625));
|
||||
var job = Job(
|
||||
new[] { Rectangle("blk", 6, 6, 6) },
|
||||
new[] { Stock("sheet", 20, 40, spacing: 0.5, clampZones: [clamp]) });
|
||||
|
||||
var result = Engine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
foreach (var placement in result.Plates.SelectMany(p => p.Placements))
|
||||
{
|
||||
// Fixed 0-degree rectangles: analytic independent oracle against the raw zone.
|
||||
Assert.False(
|
||||
placement.X < 20.75 && placement.X + 6 > 19.25 &&
|
||||
placement.Y < 0.625 && placement.Y + 6 > 0,
|
||||
$"placement at ({placement.X},{placement.Y}) intrudes into the clamp zone");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DemandLandsBetweenClampsWhenItFits()
|
||||
{
|
||||
// One row of three 2x2 parts; only [0,3], [5,7] and [9,12] are open along X.
|
||||
var clamps = new[]
|
||||
{
|
||||
new ClampZone("clamp-a", new Box(3, 0, 2, 1)),
|
||||
new ClampZone("clamp-b", new Box(7, 0, 2, 1)),
|
||||
};
|
||||
var job = Job(
|
||||
new[] { Rectangle("row", 2, 2, 3) },
|
||||
new[] { Stock("strip", 2, 12, clampZones: clamps) });
|
||||
|
||||
var result = Engine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
var placements = result.Plates.SelectMany(p => p.Placements).ToList();
|
||||
Assert.Equal(3, placements.Count);
|
||||
Assert.Contains(placements, p => System.Math.Abs(p.X - 5) < 1e-9); // the middle slot
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClampWallAcrossTheFrontEdgeBlocksTheOnlyViableRow()
|
||||
{
|
||||
// Without clamps the single viable row sits at y = bottom spacing (1.0). The full-width
|
||||
// front clamp with its edge clearance raises that floor above the work area.
|
||||
var wall = new ClampZone("rail-wall", new Box(0, 0, 10, 1));
|
||||
var job = Job(
|
||||
new[] { Rectangle("tall", 4, 4, 2) },
|
||||
new[]
|
||||
{
|
||||
Stock("sheet", 6, 10, spacing: 0,
|
||||
edge: new Spacing(left: 0.25, bottom: 1, right: 0.25, top: 0.25),
|
||||
clampZones: [wall]),
|
||||
});
|
||||
|
||||
var result = Engine().Solve(job);
|
||||
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Empty(result.Plates.SelectMany(p => p.Placements));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ZonesClearOfTheWorkAreaNeedNotShiftAnything()
|
||||
{
|
||||
// Top-edge zone the demand never reaches: layout stays identical to no clamps.
|
||||
var far = new ClampZone("back", new Box(0, 47.5, 1.5, 0.5));
|
||||
var jobClamped = Job(
|
||||
new[] { Part("rect", Rectangle(10, 5), 12) },
|
||||
new[] { Stock("sheet", 48, 96, spacing: 0.25, clampZones: [far]) });
|
||||
var jobPlain = Job(
|
||||
new[] { Part("rect", Rectangle(10, 5), 12) },
|
||||
new[] { Stock("sheet", 48, 96, spacing: 0.25) });
|
||||
|
||||
var clamped = Engine().Solve(jobClamped);
|
||||
var plain = Engine().Solve(jobPlain);
|
||||
|
||||
LayoutAssert.Valid(jobClamped, clamped);
|
||||
Assert.Equal(LayoutHash(plain), LayoutHash(clamped));
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ internal static class SheetPacker
|
||||
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 packTypes = types
|
||||
.Select(t => new PackType(
|
||||
t.Priority,
|
||||
@@ -84,6 +85,47 @@ internal static class SheetPacker
|
||||
return new SheetPlan(stock, world, area, envelope, rule, mode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks every clamp keep-out into the sheet's free list before packing. In the
|
||||
/// inflated-box model an item at position p covers material [p, p + size] plus a trailing
|
||||
/// part-spacing tail; blocking bin-local [zone - clearance + s, zone + clearance] per axis
|
||||
/// therefore means exactly: item material keeps at least the bottom-edge clearance from
|
||||
/// every clamp side, without paying any extra part-spacing tax there. A span that
|
||||
/// degenerates because the spacing exceeds the inflated zone collapses to the zone
|
||||
/// centerline - conservative (it forbids crossing the clamp), never permissive.
|
||||
/// Empty zone lists change nothing: the free list evolves bit-identically (C2).
|
||||
/// </summary>
|
||||
private static void BlockClamps(MaxRectsSheet sheet, NestPlateStock stock, Box work, double s)
|
||||
{
|
||||
if (stock.ClampZones.Count == 0)
|
||||
return;
|
||||
|
||||
var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
|
||||
foreach (var zone in stock.ClampZones)
|
||||
{
|
||||
var b = zone.Bounds; // placement frame -> bin-local packing coordinates
|
||||
double x0(double v) => v - work.Left;
|
||||
double y0(double v) => v - work.Bottom;
|
||||
var left = x0(b.Left) - clearance + s;
|
||||
var right = x0(b.Right) + clearance;
|
||||
if (right <= left)
|
||||
{
|
||||
var centerX = x0((b.Left + b.Right) * 0.5);
|
||||
left = centerX;
|
||||
right = centerX;
|
||||
}
|
||||
var bottom = y0(b.Bottom) - clearance + s;
|
||||
var top = y0(b.Top) + clearance;
|
||||
if (top <= bottom)
|
||||
{
|
||||
var centerY = y0((b.Bottom + b.Top) * 0.5);
|
||||
bottom = centerY;
|
||||
top = centerY;
|
||||
}
|
||||
sheet.Block(new Rect(left, bottom, right - left, top - bottom));
|
||||
}
|
||||
}
|
||||
|
||||
private static Box Union(Box a, Box b)
|
||||
{
|
||||
var l = System.Math.Min(a.Left, b.Left);
|
||||
|
||||
@@ -91,6 +91,20 @@ internal sealed class MaxRectsSheet
|
||||
|
||||
/// <summary>Commits an item and splits every free rectangle it intersects.</summary>
|
||||
public void Place(Rect item)
|
||||
{
|
||||
Split(item);
|
||||
used.Add(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carves a keep-out rectangle (a machine clamp's inflated exclusion, for example) out
|
||||
/// of the free space before any packing: same split logic as placements, but not a used
|
||||
/// item - it consumes no material and contributes nothing to contact scoring. The
|
||||
/// rectangle may extend past the bin; only interior intersections count.
|
||||
/// </summary>
|
||||
public void Block(Rect keepOut) => Split(keepOut);
|
||||
|
||||
private void Split(Rect item)
|
||||
{
|
||||
var next = new List<Rect>(free.Count + 8);
|
||||
foreach (var f in free)
|
||||
@@ -111,7 +125,6 @@ internal sealed class MaxRectsSheet
|
||||
}
|
||||
free.Clear();
|
||||
free.AddRange(Prune(next));
|
||||
used.Add(item);
|
||||
}
|
||||
|
||||
private static List<Rect> Prune(List<Rect> rects)
|
||||
|
||||
Reference in new issue
Block a user