feat(engine): nest on outlined stock with the Irregular packer

NestPlateStock carries the outline; its usable material (less obstacles,
eroded by the largest edge spacing) becomes the FrontierPacker's
PackBoundary. The layout check and the runner's candidate validation
reject a part outside the outline or over an obstacle, area-based cost
and the area budget use the material area, salvage credits nothing, and
Rectangles, Circles, FlatBar and the fill strategies place nothing on it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Fable 5.1 committed 2026-10-28 22:16:36 -04:00
1 parent fa7a1f0fce
commit 501d90b825
18 files changed
+392 -17

No files matched your search

@@ -0,0 +1,242 @@
using Clipper2Lib;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.NestingEngines.Default;
using OpenNest.Engine.NestingEngines.Irregular;
using OpenNest.Engine.NestingEngines.Rectangles;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs;
/// <summary>
/// Irregular stock: a <see cref="SheetOutline"/> on the stock reaches the Irregular packer as its
/// boundary, every placement stays inside the outline and off its obstacles, the layout check and
/// the runner's validation reject one that does not, and engines that only fill rectangles place
/// nothing on it.
/// </summary>
public class IrregularStockTests
{
private const double Spacing = 0.5;
/// <summary>A 96 x 48 sheet (X by Y) missing its upper-right 48 x 24 corner.</summary>
private static SheetOutline Ell() => new(new[]
{
new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(48, 24), new Vector(48, 48), new Vector(0, 48),
});
/// <summary>A 96 x 48 sheet whose upper-right corner is cut off by a diagonal from (72, 48) to (96, 24).</summary>
private static SheetOutline Chamfered() => new(new[]
{
new Vector(0, 0), new Vector(96, 0), new Vector(96, 24), new Vector(72, 48), new Vector(0, 48),
});
/// <summary>A whole 96 x 48 sheet with a 20 x 20 burned patch in the middle.</summary>
private static SheetOutline Burned() => new(
new[] { new Vector(0, 0), new Vector(96, 0), new Vector(96, 48), new Vector(0, 48) },
new[] { new[] { new Vector(38, 14), new Vector(58, 14), new Vector(58, 34), new Vector(38, 34) } });
private static NestPlateStock Stock(SheetOutline outline, int quadrant = 1, Spacing edge = default, int? quantity = 1) =>
new("drop", outline.Size, quantity, Spacing, edge, quadrant, null, null, outline);
private static NestJobPart Square(string id, double side, int quantity) =>
new(id, PartGeometrySnapshot.FromProgram(TestShapes.Rectangle(side, side)), quantity);
private static void AssertInsideOutline(NestJob job, NestJobResult result)
{
Assert.Empty(NestLayoutCheck.Violations(job, result));
foreach (var sheet in result.Plates)
{
var usable = sheet.Stock.UsableOutline();
foreach (var pose in sheet.Placements)
{
var geometry = JobPartGeometry.Read(job.Parts.Single(p => p.Id == pose.PartId).Geometry);
var perimeter = (Shape)geometry.Perimeter.Clone();
perimeter.Rotate(pose.Rotation);
perimeter.Offset(pose.X, pose.Y);
var path = ClipperBridge.ToPath(ClipperBridge.Flatten(perimeter, 0.001, circumscribe: true), positive: true);
var outside = Clipper.Difference(new PathsD { path }, usable, FillRule.NonZero, 4);
Assert.True(outside.Sum(p => System.Math.Abs(Clipper.Area(p))) <= 0,
$"{pose.PartId} at ({pose.X:F2},{pose.Y:F2}) leaves the usable outline");
}
}
}
[Fact]
public void Stock_CarriesTheOutlineAndItsArea()
{
var stock = Stock(Ell(), quadrant: 3, edge: new Spacing(1, 0.5, 0.25, 0.75));
Assert.Equal(96 * 48 - 48 * 24, stock.Area, 6);
Assert.Equal(1, stock.OutlineInset);
Assert.Equal(new Vector(-96, -48), stock.PlacementOrigin);
var usable = stock.UsableOutline();
var bounds = Clipper.GetBounds(usable);
Assert.Equal(-95, bounds.left, 6);
Assert.Equal(-47, bounds.top, 6);
Assert.Equal(-1, bounds.right, 6);
Assert.Equal(-1, bounds.bottom, 6);
Assert.Throws<ArgumentException>(() =>
new NestPlateStock("bad", new Size(48, 100), 1, Spacing, default, 1, null, null, Ell()));
}
[Fact]
public void Mapper_CarriesTheOutlineBothWays()
{
var plate = new Plate { Outline = Ell(), Quadrant = 2 };
var stock = DrawingJobMapper.FromPlate("s", plate, null);
Assert.Equal(Ell(), stock.Outline);
var rebuilt = DrawingJobMapper.CreatePlate(stock);
Assert.Equal(Ell(), rebuilt.Outline);
Assert.Equal(plate.Size, rebuilt.Size);
Assert.Equal(2, rebuilt.Quadrant);
}
[Theory]
[InlineData(1)]
[InlineData(3)]
public void Irregular_FillsAnEllSheetWithoutEnteringTheMissingCorner(int quadrant)
{
// Six 20 x 20 squares fit: four along the 96 x 24 arm and two more on the 48 x 48 column.
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell(), quadrant)]);
var result = new IrregularNestingEngine().Solve(job);
Assert.Equal(6, result.Plates.Sum(p => p.Placements.Count));
AssertInsideOutline(job, result);
}
[Fact]
public void Irregular_KeepsTheLargestEdgeSpacingFromADiagonalEdge()
{
var job = new NestJob([Square("sq", 10, 40)], [Stock(Chamfered(), edge: new Spacing(0.25, 1.5, 0.25, 0.25))]);
var result = new IrregularNestingEngine().Solve(job);
// The chamfer removes a 24 x 24 triangle (288) from 4608; at 1.5 inset about 36 squares fit.
Assert.InRange(result.Plates.Sum(p => p.Placements.Count), 30, 40);
AssertInsideOutline(job, result);
}
[Fact]
public void Irregular_NestsAroundAnObstacle()
{
var job = new NestJob([Square("sq", 15, 12)], [Stock(Burned())]);
var result = new IrregularNestingEngine().Solve(job);
Assert.True(result.Plates.Sum(p => p.Placements.Count) >= 10);
AssertInsideOutline(job, result);
}
[Fact]
public void Default_UsesIrregularForAnOutlinedSheetAndRectanglesPlacesNothing()
{
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell())]);
var rectangles = new RectanglesNestingEngine().Solve(job);
Assert.Equal(0, rectangles.Plates.Sum(p => p.Placements.Count));
Assert.Equal(NestJobStopReason.NoPlacementFound, rectangles.StopReason);
var fill = new FixedStrategyNestingEngine("Fill").Solve(job);
Assert.Equal(0, fill.Plates.Sum(p => p.Placements.Count));
var chosen = new DefaultNestingEngine().Solve(job);
Assert.Equal(6, chosen.Plates.Sum(p => p.Placements.Count));
AssertInsideOutline(job, chosen);
}
[Fact]
public void Default_PrefersARectangleWhenAnOutlinedSheetIsAlsoOffered()
{
var rectangle = new NestPlateStock("sheet", new Size(48, 96), 1, Spacing);
var job = new NestJob([Square("sq", 20, 6)], [Stock(Ell()), rectangle]);
var result = new DefaultNestingEngine().Solve(job);
Assert.Equal(6, result.Plates.Sum(p => p.Placements.Count));
Assert.Empty(NestLayoutCheck.Violations(job, result));
}
[Fact]
public void LayoutCheck_RejectsAPartInTheMissingCornerOrOverAnObstacle()
{
var corner = new NestJob([Square("sq", 20, 2)], [Stock(Ell())]);
var inCorner = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, corner.Plates[0], [new NestJobPlacement("sq", 0, 1, 1, 0), new NestJobPlacement("sq", 1, 60, 26, 0)])],
[new PartFulfillment("sq", 2, 2, 0)], [new StockUsage("drop", 1, 0)]);
var violation = Assert.Single(NestLayoutCheck.Violations(corner, inCorner));
Assert.Contains("(60.00,26.00)", violation);
Assert.Contains("outline", violation);
var burned = new NestJob([Square("sq", 20, 1)], [Stock(Burned())]);
var onPatch = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, burned.Plates[0], [new NestJobPlacement("sq", 0, 30, 20, 0)])],
[new PartFulfillment("sq", 1, 1, 0)], [new StockUsage("drop", 1, 0)]);
Assert.Contains(NestLayoutCheck.Violations(burned, onPatch), v => v.Contains("obstacle"));
// A placement inside the arm, exactly at the edge, passes.
var fine = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
[new NestJobPlateResult(0, corner.Plates[0], [new NestJobPlacement("sq", 0, 0, 0, 0), new NestJobPlacement("sq", 1, 76, 4, 0)])],
[new PartFulfillment("sq", 2, 2, 0)], [new StockUsage("drop", 1, 0)]);
Assert.Empty(NestLayoutCheck.Violations(corner, fine));
}
[Fact]
public void Runner_RefusesACandidateOutsideTheOutline()
{
var job = new NestJob([Square("sq", 20, 1)], [Stock(Ell())]);
var candidate = new PlateCandidate([new NestJobPlacement("sq", 0, 60, 26, 0)]);
var parts = job.Parts.ToDictionary(p => p.Id);
var remaining = new Dictionary<string, int> { ["sq"] = 1 };
var exception = Assert.Throws<InvalidOperationException>(() =>
NestJobValidator.ValidateCandidate(candidate, job.Plates[0], remaining, parts));
Assert.Contains("usable stock area", exception.Message);
NestJobValidator.ValidateCandidate(new PlateCandidate([new NestJobPlacement("sq", 0, 76, 4, 0)]),
job.Plates[0], remaining, parts);
}
[Fact]
public void Pipeline_CommitsTheOutlineToThePlate()
{
var nest = new Nest("drop");
var drawing = new Drawing("sq", TestShapes.Rectangle(20, 20));
drawing.Quantity.Required = 6;
nest.Drawings.Add(drawing);
var template = new Plate { Outline = Ell(), PartSpacing = Spacing };
nest.Plates.Add(template);
var request = new NestPipelineRequest("Irregular",
[new NestItem { Drawing = drawing, Quantity = 6 }], NestStockBuilder.SinglePlate(template), new NestJobOptions());
var result = NestPipeline.Run(request);
Assert.True(result.IsValid, string.Join("\n", result.Violations));
var applied = NestPipelineCommit.ApplyToPlates(result, () => template);
var plate = Assert.Single(applied);
Assert.Equal(Ell(), plate.Outline);
Assert.Equal(6, plate.Parts.Count);
}
[Fact]
public void FillService_RefusesAnOutlinedPlate()
{
var plate = new Plate { Outline = Ell() };
var item = new NestItem { Drawing = new Drawing("sq", TestShapes.Rectangle(20, 20)), Quantity = 1 };
var exception = Assert.Throws<InvalidOperationException>(() =>
PlateFillService.FillItem("Fill", plate, item, plate.WorkArea(), null, default));
Assert.Contains("rectangular", exception.Message);
}
[Fact]
public void RepeatedPlates_DoNotGroupAnOutlinedSheetWithItsRectangle()
{
var outlined = Stock(Ell(), quantity: null);
var plain = new NestPlateStock("drop", new Size(48, 96), null, Spacing);
Assert.False(RepeatedPlateGrouping.SameStock(outlined, plain));
Assert.True(RepeatedPlateGrouping.SameStock(outlined, Stock(Ell(), quantity: null)));
}
}
@@ -58,7 +58,8 @@ public static class DrawingJobMapper
plate.EdgeSpacing,
plate.Quadrant,
cost: null,
clampZones: new List<ClampZone>(plate.ClampZones)
clampZones: new List<ClampZone>(plate.ClampZones),
outline: plate.Outline
)
{ ClampRail = plate.ClampRail };
}
@@ -126,6 +127,7 @@ public static class DrawingJobMapper
// them into the placement frame through ClampZoneFrame, exactly as the stock did.
foreach (var zone in stock.SheetClampZones)
plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds));
plate.Outline = stock.Outline;
return plate;
}
}
+4 -2
View File
@@ -377,8 +377,9 @@ internal static class ClampRemovalRecovery
var e = stock.EdgeSpacing;
var zones = string.Join(",", stock.SheetClampZones.Select(z =>
FormattableString.Invariant($"{z.Id}:{z.Bounds.X:R}/{z.Bounds.Y:R}/{z.Bounds.Length:R}/{z.Bounds.Width:R}")));
var outline = stock.Outline == null ? "" : FormattableString.Invariant($"|o{stock.Outline.GetHashCode():X}");
return FormattableString.Invariant(
$"{stock.Size.Width:R}x{stock.Size.Length:R}|q{stock.Quadrant}|{stock.PartSpacing:R}|{e.Left:R},{e.Bottom:R},{e.Right:R},{e.Top:R}|{zones}");
$"{stock.Size.Width:R}x{stock.Size.Length:R}|q{stock.Quadrant}|{stock.PartSpacing:R}|{e.Left:R},{e.Bottom:R},{e.Right:R},{e.Top:R}|{zones}{outline}");
}
private sealed class ProbeBudget(int solves)
@@ -428,7 +429,8 @@ internal static class ClampRemovalRecovery
stock.EdgeSpacing,
stock.Quadrant,
stock.Cost,
sheetZones
sheetZones,
stock.Outline
)
{ ClampRail = stock.ClampRail };
}
+5 -3
View File
@@ -15,7 +15,7 @@ public static class NestJobCost
/// </summary>
public static double NetSheetArea(NestJob job, NestJobPlateResult sheet)
{
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
var area = sheet.Stock.Area;
var minimum = job.Options.MinimumSalvageDimension;
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0)
return area;
@@ -84,16 +84,18 @@ public static class NestJobCost
private static double NetSheetArea(
NestJobOptions options, NestPlateStock stock, double left, double bottom, double right, double top)
{
var area = stock.Size.Width * stock.Size.Length;
var area = stock.Area;
var minimum = options.MinimumSalvageDimension;
if (options.SalvageRate == 0 || minimum <= 0)
return area;
return area - options.SalvageRate * LargestOffcut(stock, left, bottom, right, top, minimum);
}
// No rectangular edge strip is guaranteed inside an outlined sheet, so it earns no credit.
private static double LargestOffcut(
NestPlateStock stock, double left, double bottom, double right, double top, double minimum) =>
PlateRemnant.LargestOffcut(stock.WorkArea, stock.PartSpacing, left, bottom, right, top, minimum)
stock.Outline == null
&& PlateRemnant.LargestOffcut(stock.WorkArea, stock.PartSpacing, left, bottom, right, top, minimum)
is { } offcut ? offcut.Length * offcut.Width : 0;
public static bool UsesExplicitCosts(NestJob job) =>
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Geometry;
@@ -120,7 +121,12 @@ internal static class NestJobPlacementValidator
foreach (var cutout in shape.Cutouts)
if (!FitsWorkArea(cutout, workArea))
return false;
return true;
if (stock.Outline == null)
return true;
// The circumscribed perimeter must lie in the outline's usable area; holes are inside it.
var perimeter = ClipperBridge.ToPath(
ClipperBridge.Flatten(shape.Perimeter, NestTolerances.ValidationOutline, circumscribe: true), positive: true);
return stock.OutlineHolds(new PathsD { perimeter }, Epsilon);
}
private static bool FitsWorkArea(Shape contour, Box workArea)
+2
View File
@@ -63,6 +63,8 @@ public sealed class NestJobRunner : INestingEngine
var stock = job.Plates[index];
if (stock.Quantity is int quantity && used[stock.Id] >= quantity)
continue;
if (stock.Outline != null)
continue; // Single-plate fill strategies fill rectangles only.
hasAvailableStock = true;
var request = new PlatePlacementRequest(
stock,
+19 -1
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Clipper2Lib;
using OpenNest.Converters;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Geometry;
@@ -394,6 +395,7 @@ public static class NestLayoutCheck
&& expected.EdgeSpacing.Top.IsEqualTo(actual.EdgeSpacing.Top)
&& expected.EdgeSpacing.Bottom.IsEqualTo(actual.EdgeSpacing.Bottom)
&& expected.Quadrant == actual.Quadrant
&& Equals(expected.Outline, actual.Outline)
&& SameZones(expected.ClampZones, actual.ClampZones)
);
@@ -465,6 +467,7 @@ public static class NestLayoutCheck
)
{
var workArea = plate.WorkArea();
var stock = plate.Outline == null ? null : DrawingJobMapper.FromPlate("plate", plate, null);
foreach (var part in parts)
{
@@ -481,6 +484,19 @@ public static class NestLayoutCheck
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the work area "
+ $"of a {plate.Size} plate"
);
continue;
}
// An outlined sheet: the circumscribed material must lie in the usable area, which
// keeps the largest edge spacing from every edge and obstacle, within the same slack.
if (stock != null && Outline(part, 0) is { } material
&& !stock.OutlineHolds(new PathsD { ClipperBridge.ToPath(material.Perimeter, positive: true) },
NestTolerances.WorkAreaSlack))
{
result.Add(
$"'{DisplayName(part, requirements)}' at ({part.Location.X:F2},{part.Location.Y:F2}) falls outside the outline "
+ $"of a {plate.Size} plate or over one of its obstacles"
);
}
}
}
@@ -501,7 +517,9 @@ public static class NestLayoutCheck
)
{
var workArea = plate.WorkArea();
var budget = workArea.Width * workArea.Length;
var budget = plate.Outline == null
? workArea.Width * workArea.Length
: DrawingJobMapper.FromPlate("plate", plate, null).UsableOutline().Sum(Clipper.Area);
var placedArea = parts.Sum(p => p.BaseDrawing.Area);
if (placedArea > budget + Tolerance.Epsilon)
@@ -89,6 +89,7 @@ public static class NestPipelineCommit
continue;
var plate = nextPlate();
plate.Size = proposed.Stock.Size;
plate.Outline = proposed.Stock.Outline;
plate.PartSpacing = proposed.Stock.PartSpacing;
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
plate.Quadrant = proposed.Stock.Quadrant;
+46 -3
View File
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Clipper2Lib;
using OpenNest.Geometry;
namespace OpenNest.Engine.Jobs;
@@ -22,16 +23,19 @@ public sealed class NestPlateStock
public NestPlateStock(string id, Size size, int? quantity, double partSpacing,
Spacing edgeSpacing, int quadrant, double? cost,
IReadOnlyList<ClampZone> clampZones = null)
IReadOnlyList<ClampZone> clampZones = null, SheetOutline outline = null)
{
if (cost.HasValue && (!double.IsFinite(cost.Value) || cost.Value <= 0))
throw new ArgumentOutOfRangeException(nameof(cost), $"Stock {id}: cost must be finite and positive, or null.");
ArgumentException.ThrowIfNullOrWhiteSpace(id);
if (quantity < 0)
throw new ArgumentOutOfRangeException(nameof(quantity));
if (outline != null && (outline.Size.Width != size.Width || outline.Size.Length != size.Length))
throw new ArgumentException($"Stock {id}: the outline's bounds ({outline.Size}) must be the sheet size ({size}).", nameof(outline));
Cost = cost;
Id = id;
Size = size;
Outline = outline;
Quantity = quantity;
PartSpacing = partSpacing;
EdgeSpacing = edgeSpacing;
@@ -92,8 +96,46 @@ public sealed class NestPlateStock
/// <summary>Generic per-physical-sheet cost; null selects area scoring.</summary>
public double? Cost { get; }
public string Id { get; }
/// <summary>The sheet's bounding box; the whole sheet when <see cref="Outline"/> is null.</summary>
public Size Size { get; }
/// <summary>
/// The sheet's material when it is not a rectangle, in plate coordinates (the sheet's
/// lower-left frame, as <see cref="SheetClampZones"/>); null for a rectangular sheet. Its
/// bounds are <see cref="Size"/>. Only engines that fill an outline nest on such stock;
/// the others place nothing on it. Parts keep <see cref="OutlineInset"/> from its edges
/// and obstacles.
/// </summary>
public SheetOutline Outline { get; }
/// <summary>
/// The clearance a part keeps from an outlined sheet's edges and obstacles: the largest of
/// the four edge spacings, so no side of the outline is closer than the rectangle's would be.
/// </summary>
public double OutlineInset =>
System.Math.Max(0, System.Math.Max(System.Math.Max(EdgeSpacing.Left, EdgeSpacing.Right),
System.Math.Max(EdgeSpacing.Top, EdgeSpacing.Bottom)));
/// <summary>The sheet's lower-left corner in the placement frame: the translation
/// <see cref="WorkArea"/> and <see cref="ClampZones"/> were given.</summary>
public Vector PlacementOrigin => new(workArea.X - EdgeSpacing.Left, workArea.Y - EdgeSpacing.Bottom);
/// <summary>
/// Where a part may lie on an outlined sheet, in the placement frame: the outline less its
/// obstacles, eroded by <see cref="OutlineInset"/>. Null for a rectangular sheet, whose
/// <see cref="WorkArea"/> is the answer. A fresh copy on every call.
/// </summary>
public PathsD UsableOutline() => Outline?.Usable(OutlineInset, PlacementOrigin);
/// <summary>
/// Whether <paramref name="region"/> (outer paths positive, holes negative, in the placement
/// frame) lies within the outlined sheet's usable area, allowing <paramref name="slack"/>
/// outside it. True for a rectangular sheet: its box is checked by the callers.
/// </summary>
public bool OutlineHolds(PathsD region, double slack) =>
Outline == null || Outline.Holds(region, OutlineInset, PlacementOrigin, slack);
/// <summary>Available physical sheets: null is unlimited, zero is legal but unavailable.</summary>
public int? Quantity { get; }
public double PartSpacing { get; }
@@ -106,8 +148,9 @@ public sealed class NestPlateStock
/// </summary>
public Box WorkArea => new(workArea.X, workArea.Y, workArea.Length, workArea.Width);
/// <summary>Full sheet area before edge spacing is removed.</summary>
public double Area => Size.Width * Size.Length;
/// <summary>Full sheet area before edge spacing is removed: the material inside the
/// <see cref="Outline"/> when there is one.</summary>
public double Area => Outline?.Area ?? Size.Width * Size.Length;
/// <summary>True when a width (X) by height (Y) envelope fits the work area within epsilon.</summary>
public bool Fits(double width, double height, double epsilon = 1e-9) =>
@@ -431,6 +431,11 @@ public static class PlateFillService
private static PlateFiller RequireFiller(string strategy, Plate plate)
{
ArgumentNullException.ThrowIfNull(strategy);
ArgumentNullException.ThrowIfNull(plate);
// Every filler lays parts out in a rectangle; only Auto Nest's Irregular engine honors an outline.
if (plate.Outline != null)
throw new InvalidOperationException(
"Fill strategies need a rectangular sheet. Nest an outlined sheet with Auto Nest and the Irregular engine.");
// An explicit empty string is an unknown strategy; only ResolveStrategy(string),
// used for caller selections, maps null/empty to Fill.
ResolveStrategy(strategy, allowEmpty: false);
@@ -40,6 +40,7 @@ internal static class RepeatedPlateGrouping
&& Bits(a.EdgeSpacing.Top) == Bits(b.EdgeSpacing.Top)
&& Bits(a.EdgeSpacing.Bottom) == Bits(b.EdgeSpacing.Bottom)
&& a.Quantity == b.Quantity
&& Equals(a.Outline, b.Outline)
&& a.Cost.HasValue == b.Cost.HasValue
&& (!a.Cost.HasValue || Bits(a.Cost.Value) == Bits(b.Cost.Value))
&& SameZones(a.ClampZones, b.ClampZones);
@@ -90,6 +90,8 @@ internal static class DiscPacker
/// <summary>True when the disc fits the work area in both directions.</summary>
public static bool Fits(NestPlateStock stock, DiscType type)
{
if (stock.Outline != null)
return false; // The lattice fills rectangles only.
var work = stock.WorkArea;
return 2 * type.Radius <= work.Right - work.Left + Epsilon
&& 2 * type.Radius <= work.Top - work.Bottom + Epsilon;
@@ -131,9 +131,12 @@ internal sealed class BarStock
var lengths = new double[types.Count];
for (var t = 0; t < types.Count; t++)
{
var fitting = types[t].Orientations
.Where(o => (alongX ? o.Height : o.Width) <= crossExtent + BarPacker.OverhangAllowance)
.ToList();
// A bar is a rectangle; an outlined sheet is left to engines that fill outlines.
var fitting = stock.Outline != null
? new List<BarOrientation>()
: types[t].Orientations
.Where(o => (alongX ? o.Height : o.Width) <= crossExtent + BarPacker.OverhangAllowance)
.ToList();
if (fitting.Count == 0)
{
fits[t] = Array.Empty<BarOrientation>();
@@ -149,6 +149,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<NestPlateStock, PackBoundary?> boundaries = 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);
@@ -230,6 +231,18 @@ public sealed class IrregularNestingEngine : INestingEngine
return cache;
}
/// <summary>An outlined sheet's usable material as the packer's boundary, built once per
/// stock per solve; null for a rectangular sheet, which keeps the work-area path.</summary>
private PackBoundary? BoundaryFor(NestPlateStock stock)
{
if (stock.Outline == null)
return null;
if (!boundaries.TryGetValue(stock, out var found))
boundaries[stock] = found = new PackBoundary(stock.Outline.Material(stock.PlacementOrigin),
stock.OutlineInset, stock.WorkArea, CacheFor(stock));
return found;
}
private ClampObstacles? ClampsFor(NestPlateStock stock)
{
if (stock.ClampZones.Count == 0)
@@ -307,7 +320,7 @@ public sealed class IrregularNestingEngine : INestingEngine
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), ClampsFor(stock));
BlocksFor(stock), ClampsFor(stock), BoundaryFor(stock));
var fill = packer.Fill(remaining, token);
sheetPacked?.Invoke();
if (fill.Parts.Count > 0)
@@ -64,6 +64,25 @@ internal sealed class PackBoundary
}
}
/// <summary>
/// A sheet's material as the boundary: <paramref name="material"/> is its outline less its
/// obstacles (outer paths positive, holes negative) in the placement frame, and parts keep
/// <paramref name="edgeClearance"/> from every edge and obstacle, as they keep the edge
/// spacing from a rectangular sheet's edges. The outline is eroded by the edge clearance
/// less the half clearance the footprints already carry (grown when that is negative), plus
/// the margin; <paramref name="bounds"/> is the packer's work area and front origin.
/// </summary>
public PackBoundary(PathsD material, double edgeClearance, Box bounds, NoFitCache nfps)
{
Nfps = nfps;
Bounds = bounds;
var inset = edgeClearance - nfps.Clearance / 2 + Margin;
eroded = Clipper.InflatePaths(material, -inset, JoinType.Round, EndType.Polygon, 2.0,
NoFitCache.Precision, ArcTolerance);
eroded.RemoveAll(p => p.Count < 3);
obstacles = System.Array.Empty<Orientation>();
}
public NoFitCache Nfps { get; }
/// <summary>Bounds of the outline: the packer's work area and front origin.</summary>
@@ -76,6 +76,8 @@ public sealed class RectanglesNestingEngine : INestingEngine
private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
{
if (stock.Outline != null)
return false; // Boxes pack rectangles only.
var work = stock.WorkArea;
return o.Width <= work.Right - work.Left + SheetPacker.OverhangAllowance
&& o.Height <= work.Top - work.Bottom + SheetPacker.OverhangAllowance;
@@ -69,11 +69,12 @@ internal static class SheetByStock
}
var trials = new List<(SheetLayout Layout, double Net, double Offcut)>();
if (preferred != null)
// Box and disc packers fill rectangles only; an outlined sheet is left to engines that fill outlines.
if (preferred != null && preferred.Outline == null)
Try(preferred, trials);
if (trials.Count == 0)
foreach (var stock in job.Plates)
if (!ReferenceEquals(stock, preferred))
if (!ReferenceEquals(stock, preferred) && stock.Outline == null)
Try(stock, trials);
if (trials.Count == 0)
+11
View File
@@ -171,6 +171,17 @@ Limits: one part per position across the width, so narrow parts waste the rest o
that reach the row block their whole clearance-expanded length; FlatBar is not a Default
candidate and must be selected by name.
## Irregular stock
A stock whose `NestPlateStock.Outline` is set (see [irregular stock](irregular-stock.md)) is
filled only by Irregular: its usable material (the outline less obstacles, eroded by the largest
edge spacing) becomes the `FrontierPacker`'s `PackBoundary`, built once per stock per solve on
the stock's spacing cache, with the rectangular work area as the packer's bounds. Rectangles,
Circles and FlatBar place nothing on it (`SheetByStock` skips it, their fit tests say no), the
single-plate fill strategies skip it in the runner, and Default keeps Irregular's layout when
it is the only valid one. The layout check rejects a part outside the outline or over an
obstacle, so a plug-in that ignores the outline is caught as any other invalid layout.
## Rectangular clamp zones
Whole-job Rectangles and Irregular exclude each stock's rectangular clamp zones,