mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-17 23:11:40 -04:00
feat(irregular): treat clamp zones as static NFP obstacles
This commit is contained in:
1 parent
de9a087027
commit
c374e02614
6 files changed
+340
-8
No files matched your search
@@ -0,0 +1,239 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.NestingEngines.Default;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
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;
|
||||
|
||||
public class IrregularClampZoneTests
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<int, IReadOnlyList<PairPose>> NoPairs =
|
||||
new Dictionary<int, IReadOnlyList<PairPose>>();
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(2)]
|
||||
[InlineData(3)]
|
||||
[InlineData(4)]
|
||||
public void CurvedPartsClearEveryInflatedClampInEveryQuadrant(int quadrant)
|
||||
{
|
||||
var stock = Stock("sheet", 10, 20, spacing: 0.1, quadrant: quadrant,
|
||||
edge: new Spacing(0.1, 0.25, 0.1, 0.1), clampZones:
|
||||
[new("left", new Box(0, 0, 3, 0.625)), new("middle", new Box(7, 0, 1.5, 0.625))]);
|
||||
var job = Job([Part("disc", Disc(1), 9, RotationPolicy.Fixed(0))], [stock]);
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(9, Assert.Single(result.Plates).Placements.Count);
|
||||
AssertDiscsClearClamps(result, 1);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Contains(result.Plates[0].Placements, p => p.Y - 1 < stock.WorkArea.Bottom + 0.05);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Irregular")]
|
||||
[InlineData("Rectangles")]
|
||||
[InlineData("Default")]
|
||||
public void BothLanesAndDefaultAvoidClamps(string engine)
|
||||
{
|
||||
var job = Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))],
|
||||
[Stock("sheet", 5, 12, edge: new Spacing(0.25, 0.25, 0.25, 0.25),
|
||||
clampZones: [new("front", new Box(0, 0, 3, 0.625))])]);
|
||||
INestingEngine solver = engine switch
|
||||
{
|
||||
"Irregular" => new IrregularNestingEngine(),
|
||||
"Rectangles" => new RectanglesNestingEngine(),
|
||||
_ => new DefaultNestingEngine(),
|
||||
};
|
||||
var result = solver.Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
AssertDiscsClearClamps(result, 1);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(4)]
|
||||
public void ClampClearanceUsesBottomEdgeSpacingNotPartSpacing(double partSpacing)
|
||||
{
|
||||
var stock = Stock("sheet", 4, 5, spacing: partSpacing,
|
||||
edge: new Spacing(0, 0.25, 0, 0),
|
||||
clampZones: [new("rail", new Box(0, 0, 5, 0.625))]);
|
||||
var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]);
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
var pose = Assert.Single(Assert.Single(result.Plates).Placements);
|
||||
Assert.InRange(pose.Y - 1, 0.875, 0.90);
|
||||
AssertDiscsClearClamps(result, 1);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FullRailBlocksTheOnlyRowInsteadOfReturningAnUnsafePlacement()
|
||||
{
|
||||
var stock = Stock("sheet", 2.5, 6, edge: new Spacing(0, 0.25, 0, 0),
|
||||
clampZones: [new("rail", new Box(0, 0, 6, 0.625))]);
|
||||
var job = Job([Part("disc", Disc(1), 1, RotationPolicy.Fixed(0))], [stock]);
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Incomplete, result.Status);
|
||||
Assert.Empty(result.Plates);
|
||||
Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(1)]
|
||||
public void PairOnlyOrientationsAlsoLoseClampBlockedSpace(int axis)
|
||||
{
|
||||
var stock = Stock("sheet", 10, 10, edge: new Spacing(0, 0.25, 0, 0),
|
||||
clampZones: [new("rail", new Box(0, 0, 10, 1))]);
|
||||
var job = Job([Rectangle("bar", 2, 3, 2, RotationPolicy.Automatic)], [stock]);
|
||||
var types = PartCatalog.Build(job);
|
||||
var extra = PartCatalog.CreateOrientation(types[0], 100, System.Math.PI / 2)!;
|
||||
var pair = new PairPose { A = extra, B = extra, Dx = 0, Dy = 3 };
|
||||
var pairs = new Dictionary<int, IReadOnlyList<PairPose>> { [0] = new[] { pair } };
|
||||
var fill = new FrontierPacker(types, new NoFitCache(0), pairs, stock,
|
||||
(PackAxis)axis, 1, new WorkCounter()).Fill([2], default);
|
||||
|
||||
Assert.Equal(2, fill.Parts.Count);
|
||||
Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25));
|
||||
if (axis == 0)
|
||||
Assert.All(fill.Parts, p => Assert.Same(extra, p.Orientation));
|
||||
AssertValidFill(job, fill);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlockOnlyOrientationsAlsoLoseClampBlockedSpace()
|
||||
{
|
||||
var stock = Stock("sheet", 20, 12, edge: new Spacing(0, 0.25, 0, 0),
|
||||
clampZones: [new("rail", new Box(0, 0, 12, 1))]);
|
||||
var job = Job([Rectangle("bar", 2, 3, 6, RotationPolicy.Automatic)], [stock]);
|
||||
var types = PartCatalog.Build(job);
|
||||
using var blocks = new BlockCatalog(0.1, types, NoPairs, new WorkCounter(), long.MaxValue);
|
||||
var fill = new FrontierPacker(types, new NoFitCache(0.1), NoPairs, stock,
|
||||
PackAxis.Y, 1, new WorkCounter(), blocks).Fill([6], default);
|
||||
|
||||
Assert.Equal(6, fill.Parts.Count);
|
||||
Assert.True(blocks.PreparationCount > 0);
|
||||
Assert.Contains(fill.Parts, p => !types[0].Orientations.Contains(p.Orientation));
|
||||
Assert.All(fill.Parts, p => Assert.True(p.Bottom >= 1.25));
|
||||
AssertValidFill(job, fill);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameSpacingStocksDoNotShareClampGeometry()
|
||||
{
|
||||
var wall = new ClampZone("rail", new Box(0, 0, 6, 1));
|
||||
var job = Job([Rectangle("part", 2, 2, 1)],
|
||||
[Stock("blocked", 2.5, 6, clampZones: [wall]), Stock("open", 2.5, 6)]);
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("open", Assert.Single(result.Plates).StockId);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConcavePartCanUseItsOpenNotchAroundAClamp()
|
||||
{
|
||||
// The only fitting pose surrounds the rail in an open notch. Blocking the
|
||||
// part's bounding box instead of its NFP would incorrectly reject this job.
|
||||
var program = Polyline((0, 0), (1, 0), (1, 3), (3, 3),
|
||||
(3, 0), (4, 0), (4, 4), (0, 4));
|
||||
var stock = Stock("sheet", 4.2, 4, edge: new Spacing(0, 0.1, 0, 0),
|
||||
clampZones: [new("notch", new Box(1.5, 0, 1, 2))]);
|
||||
var job = Job([Part("notched", program, 1, RotationPolicy.Fixed(0))], [stock]);
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
var pose = Assert.Single(Assert.Single(result.Plates).Placements);
|
||||
Assert.Equal(0, pose.X, 8);
|
||||
Assert.Equal(0.1, pose.Y, 8);
|
||||
// Analytic notch walls versus the rectangle inflated by 0.1.
|
||||
Assert.True(pose.X + 1 <= 1.4 && pose.X + 3 >= 2.6 && pose.Y + 3 >= 2.1);
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DifferentClampedStocksAtTheSameSpacingKeepTheirOwnObstacles()
|
||||
{
|
||||
var job = Job([Rectangle("part", 2, 2, 2)],
|
||||
[Stock("left", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(0, 0, 3, 1))]),
|
||||
Stock("right", 2.5, 6, quantity: 1, clampZones: [new("rail", new Box(3, 0, 3, 1))])]);
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, result.Plates.Count);
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
var pose = Assert.Single(sheet.Placements);
|
||||
Assert.True(sheet.StockId == "left" ? pose.X >= 3 : pose.X + 2 <= 3);
|
||||
}
|
||||
LayoutAssert.Valid(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancelledClampPackingDoesNotReturnAnEmptySuccess()
|
||||
{
|
||||
var stock = Stock("sheet", 4, 5, clampZones: [new("rail", new Box(0, 0, 5, 1))]);
|
||||
var job = Job([Rectangle("part", 2, 2, 1)], [stock]);
|
||||
var packer = new FrontierPacker(PartCatalog.Build(job), new NoFitCache(0), NoPairs,
|
||||
stock, PackAxis.X, 1, new WorkCounter());
|
||||
Assert.Throws<OperationCanceledException>(() => packer.Fill([1], new CancellationToken(true)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, "0F90BA3EF6C6392CF1C1E01A6209D55CA88B4F968D6DD0D01850AE15A349FE88")]
|
||||
[InlineData(1, "A970D56C5768763CE339999F91ADD7FFB9EC2AF6C1585CC51C4B0869D45D0C53")]
|
||||
[InlineData(2, "65ECA7F7163DB0AB7BCA331FBF2A240604B25C44D62CE97CB2015FC78A9631AA")]
|
||||
// Full result hashes captured on d71f086 before static-obstacle preparation.
|
||||
public void EmptyClampLayoutsKeepBaselineBits(int fixture, string expected)
|
||||
{
|
||||
var job = fixture switch
|
||||
{
|
||||
0 => Job([Part("disc", Disc(1), 2, RotationPolicy.Fixed(0))], [Stock("sheet", 5, 12, spacing: 0.25)]),
|
||||
1 => Job([Part("ell", LShape(4, 3, 1), 1), Part("tri", Triangle(3, 2), 1)],
|
||||
[Stock("sheet", 10, 15, spacing: 0.25)]),
|
||||
_ => Job([Rectangle("square", 4, 4, 3)], [Stock("sheet", 5, 5, spacing: 0.25)]),
|
||||
};
|
||||
var result = new IrregularNestingEngine().Solve(job);
|
||||
LayoutAssert.Valid(job, result);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(result))));
|
||||
Assert.Equal(expected, hash);
|
||||
}
|
||||
|
||||
private static void AssertDiscsClearClamps(NestJobResult result, double radius)
|
||||
{
|
||||
foreach (var sheet in result.Plates)
|
||||
foreach (var pose in sheet.Placements)
|
||||
foreach (var zone in sheet.Stock.ClampZones)
|
||||
{
|
||||
var b = zone.Bounds;
|
||||
var c = sheet.Stock.EdgeSpacing.Bottom;
|
||||
var dx = pose.X - System.Math.Clamp(pose.X, b.Left - c, b.Right + c);
|
||||
var dy = pose.Y - System.Math.Clamp(pose.Y, b.Bottom - c, b.Top + c);
|
||||
Assert.True(dx * dx + dy * dy >= radius * radius,
|
||||
$"Disc at ({pose.X:R}, {pose.Y:R}) enters inflated clamp {zone.Id}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertValidFill(NestJob job, SheetFill fill)
|
||||
{
|
||||
var builder = new NestJobResultBuilder(job);
|
||||
builder.AddSheet(fill.Stock, fill.Parts.Select(p =>
|
||||
(job.Parts[p.Orientation.TypeIndex].Id, p.X, p.Y, p.Orientation.Rotation)));
|
||||
LayoutAssert.Valid(job, builder.Build(NestJobStopReason.Completed));
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,8 @@ 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.
|
||||
/// clamps stay bit-identical to the pre-clamp baseline (C2). Irregular and Default
|
||||
/// parity is covered separately by IrregularClampZoneTests.
|
||||
/// </summary>
|
||||
public class RectanglesClampZoneTests
|
||||
{
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#nullable enable
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// Static, clearance-inflated rectangles prepared once per stock per solve. Kept out of
|
||||
/// the part catalog and its spacing cache: clamp clearance is bottom edge spacing, not
|
||||
/// part spacing, and two stocks at the same part spacing may have different clamps.
|
||||
/// </summary>
|
||||
internal sealed class ClampObstacles
|
||||
{
|
||||
private readonly Orientation[] rectangles;
|
||||
private readonly NoFitCache nfps = new(0);
|
||||
|
||||
public ClampObstacles(NestPlateStock stock)
|
||||
{
|
||||
rectangles = new Orientation[stock.ClampZones.Count];
|
||||
var clearance = System.Math.Max(0, stock.EdgeSpacing.Bottom);
|
||||
for (var i = 0; i < rectangles.Length; i++)
|
||||
{
|
||||
var box = stock.ClampZones[i].Bounds;
|
||||
var left = box.Left - clearance;
|
||||
var bottom = box.Bottom - clearance;
|
||||
var right = box.Right + clearance;
|
||||
var top = box.Top + clearance;
|
||||
rectangles[i] = new Orientation
|
||||
{
|
||||
// Real part types are nonnegative; the private cache cannot alias them.
|
||||
TypeIndex = -1,
|
||||
Index = i,
|
||||
Rotation = 0,
|
||||
Tolerance = 0,
|
||||
MinX = left,
|
||||
MinY = bottom,
|
||||
MaxX = right,
|
||||
MaxY = top,
|
||||
Outline = new PathD
|
||||
{
|
||||
new(left, bottom), new(right, bottom), new(right, top), new(left, top),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public int Count => rectangles.Length;
|
||||
|
||||
// No additional physical spacing. NoFitCache retains the established arc/grid safety
|
||||
// margin and covered-hole certification for the moving orientation and rectangle.
|
||||
public Nfp Get(int index, Orientation moving) => nfps.Get(rectangles[index], moving);
|
||||
}
|
||||
@@ -35,8 +35,8 @@ internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList<Placed> Par
|
||||
///
|
||||
/// For every (part type, orientation) still in play the packer keeps the exact set of legal
|
||||
/// reference points: the inner-fit rectangle of the work area minus the no-fit polygons of
|
||||
/// everything already placed. Each placement subtracts one translated NFP from each region,
|
||||
/// so regions only shrink, and a region that empties is retired for the rest of the sheet.
|
||||
/// static clamps and everything already placed. Each placement subtracts one translated NFP
|
||||
/// from each region, so regions only shrink; an empty region is retired for the sheet.
|
||||
///
|
||||
/// Choice rule, applied over all types and orientations at once (not in a fixed order):
|
||||
/// 1. Gap fill - if any part fits without pushing the packing front forward, place the
|
||||
@@ -62,6 +62,7 @@ internal sealed class FrontierPacker
|
||||
private readonly Box work;
|
||||
private readonly WorkCounter counter;
|
||||
private readonly BlockCatalog? blocks;
|
||||
private readonly ClampObstacles? clamps;
|
||||
|
||||
public FrontierPacker(
|
||||
IReadOnlyList<PartType> types,
|
||||
@@ -71,10 +72,12 @@ internal sealed class FrontierPacker
|
||||
PackAxis axis,
|
||||
double beta,
|
||||
WorkCounter counter,
|
||||
BlockCatalog? blocks = null
|
||||
BlockCatalog? blocks = null,
|
||||
ClampObstacles? clamps = null
|
||||
)
|
||||
{
|
||||
this.blocks = blocks;
|
||||
this.clamps = stock.ClampZones.Count == 0 ? null : clamps ?? new ClampObstacles(stock);
|
||||
this.counter = counter;
|
||||
this.types = types;
|
||||
this.nfps = nfps;
|
||||
@@ -97,6 +100,16 @@ internal sealed class FrontierPacker
|
||||
if (!stock.Fits(o.Width, o.Height))
|
||||
return false;
|
||||
var region = new Region(o, work, single);
|
||||
if (clamps != null)
|
||||
for (var i = 0; i < clamps.Count; i++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var nfp = clamps.Get(i, o);
|
||||
token.ThrowIfCancellationRequested();
|
||||
region.Subtract(nfp, 0, 0);
|
||||
counter.Add(1);
|
||||
token.ThrowIfCancellationRequested();
|
||||
}
|
||||
states.Add(region);
|
||||
byOrientation[o] = region;
|
||||
return true;
|
||||
|
||||
@@ -118,6 +118,7 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
|
||||
private const int MaxTail = 3;
|
||||
private readonly Dictionary<double, NoFitCache> caches = new();
|
||||
private readonly Dictionary<NestPlateStock, ClampObstacles> clamps = new();
|
||||
private readonly Dictionary<double, IReadOnlyDictionary<int, IReadOnlyList<PairPose>>> pairs = new();
|
||||
private readonly Dictionary<double, BlockCatalog> blocks = new();
|
||||
private readonly IReadOnlyDictionary<string, NestJobPart> parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
||||
@@ -198,6 +199,15 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
return cache;
|
||||
}
|
||||
|
||||
private ClampObstacles? ClampsFor(NestPlateStock stock)
|
||||
{
|
||||
if (stock.ClampZones.Count == 0)
|
||||
return null;
|
||||
if (!clamps.TryGetValue(stock, out var found))
|
||||
clamps[stock] = found = new ClampObstacles(stock);
|
||||
return found;
|
||||
}
|
||||
|
||||
public bool PairFits(PartType type)
|
||||
{
|
||||
if (type.Part.Quantity < 2 || type.Orientations.Count == 0)
|
||||
@@ -265,7 +275,8 @@ public sealed class IrregularNestingEngine : INestingEngine
|
||||
if (stock.Quantity is int available && used[stock.Id] >= available)
|
||||
continue;
|
||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
|
||||
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work, BlocksFor(stock));
|
||||
var packer = new FrontierPacker(types, CacheFor(stock), PairsFor(stock), stock, axis, beta, Work,
|
||||
BlocksFor(stock), ClampsFor(stock));
|
||||
var fill = packer.Fill(remaining, token);
|
||||
sheetPacked?.Invoke();
|
||||
if (fill.Parts.Count > 0)
|
||||
|
||||
@@ -67,6 +67,25 @@ or invalid Fill proposal leaves singles and pairs available. Large enclosed-pock
|
||||
pending the hole-geometry integration; containment cutting order and shop-use safety acceptance
|
||||
remain separate sequencer/verification work.
|
||||
|
||||
## Rectangular clamp zones
|
||||
|
||||
Whole-job Rectangles and Irregular exclude each stock's rectangular clamp zones,
|
||||
expanded on all sides by its bottom edge spacing. Part spacing is not added to clamp
|
||||
clearance. Default runs and validates both lanes as usual. Empty clamp collections
|
||||
leave the existing packing path unchanged; clamps never become requested or placed parts.
|
||||
|
||||
Irregular prepares static rectangle obstacles and their cached no-fit polygons once
|
||||
per stock per solve, separately from the part-spacing cache. Every orientation's initial
|
||||
free region excludes these obstacles, including extra orientations introduced by pairs
|
||||
or Fill blocks. Blocks still size their proposals from work-area/placed-part rectangles;
|
||||
member free regions determine whether the whole proposal can actually fit around clamps.
|
||||
Open notches and material between clamps remain available. Existing arc/grid safety
|
||||
margins, covered-hole certification and cancellation apply without relaxed tolerances.
|
||||
|
||||
This is whole-job engine support, not interactive fill/push, desktop clamp editing or
|
||||
cutoff/post-verification support; those consumers are separate work. Other engines and
|
||||
plug-ins have not gained clamp-aware placement from this change.
|
||||
|
||||
## Repeating completed layouts
|
||||
|
||||
Rectangles and Irregular can skip repeated sheet searches after selecting one nonempty layout
|
||||
|
||||
Reference in new issue
Block a user