diff --git a/OpenNest.Engine.Tests/Jobs/IrregularStockTests.cs b/OpenNest.Engine.Tests/Jobs/IrregularStockTests.cs
new file mode 100644
index 00000000..68af7a19
--- /dev/null
+++ b/OpenNest.Engine.Tests/Jobs/IrregularStockTests.cs
@@ -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;
+
+///
+/// Irregular stock: a 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.
+///
+public class IrregularStockTests
+{
+ private const double Spacing = 0.5;
+
+ /// A 96 x 48 sheet (X by Y) missing its upper-right 48 x 24 corner.
+ 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),
+ });
+
+ /// A 96 x 48 sheet whose upper-right corner is cut off by a diagonal from (72, 48) to (96, 24).
+ 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),
+ });
+
+ /// A whole 96 x 48 sheet with a 20 x 20 burned patch in the middle.
+ 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(() =>
+ 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 { ["sq"] = 1 };
+
+ var exception = Assert.Throws(() =>
+ 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(() =>
+ 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)));
+ }
+}
diff --git a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
index fc3b1ca5..bc0a7c81 100644
--- a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
+++ b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs
@@ -58,7 +58,8 @@ public static class DrawingJobMapper
plate.EdgeSpacing,
plate.Quadrant,
cost: null,
- clampZones: new List(plate.ClampZones)
+ clampZones: new List(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;
}
}
diff --git a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs
index d5194caa..f43a49b8 100644
--- a/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs
+++ b/OpenNest.Engine/Jobs/ClampRemovalRecovery.cs
@@ -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 };
}
diff --git a/OpenNest.Engine/Jobs/NestJobCost.cs b/OpenNest.Engine/Jobs/NestJobCost.cs
index 4638dd9e..a2dd355f 100644
--- a/OpenNest.Engine/Jobs/NestJobCost.cs
+++ b/OpenNest.Engine/Jobs/NestJobCost.cs
@@ -15,7 +15,7 @@ public static class NestJobCost
///
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) =>
diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
index a7dcba71..a0736c2b 100644
--- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
+++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs
@@ -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)
diff --git a/OpenNest.Engine/Jobs/NestJobRunner.cs b/OpenNest.Engine/Jobs/NestJobRunner.cs
index 6ba05a89..0133dee0 100644
--- a/OpenNest.Engine/Jobs/NestJobRunner.cs
+++ b/OpenNest.Engine/Jobs/NestJobRunner.cs
@@ -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,
diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
index 55f459f9..76e47179 100644
--- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs
+++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs
@@ -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)
diff --git a/OpenNest.Engine/Jobs/NestPipelineCommit.cs b/OpenNest.Engine/Jobs/NestPipelineCommit.cs
index 3cbfcab1..ab6b5107 100644
--- a/OpenNest.Engine/Jobs/NestPipelineCommit.cs
+++ b/OpenNest.Engine/Jobs/NestPipelineCommit.cs
@@ -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;
diff --git a/OpenNest.Engine/Jobs/NestPlateStock.cs b/OpenNest.Engine/Jobs/NestPlateStock.cs
index 9cb86383..8ff0cb1e 100644
--- a/OpenNest.Engine/Jobs/NestPlateStock.cs
+++ b/OpenNest.Engine/Jobs/NestPlateStock.cs
@@ -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 clampZones = null)
+ IReadOnlyList 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
/// Generic per-physical-sheet cost; null selects area scoring.
public double? Cost { get; }
public string Id { get; }
+
+ /// The sheet's bounding box; the whole sheet when is null.
public Size Size { get; }
+ ///
+ /// The sheet's material when it is not a rectangle, in plate coordinates (the sheet's
+ /// lower-left frame, as ); null for a rectangular sheet. Its
+ /// bounds are . Only engines that fill an outline nest on such stock;
+ /// the others place nothing on it. Parts keep from its edges
+ /// and obstacles.
+ ///
+ public SheetOutline Outline { get; }
+
+ ///
+ /// 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.
+ ///
+ public double OutlineInset =>
+ System.Math.Max(0, System.Math.Max(System.Math.Max(EdgeSpacing.Left, EdgeSpacing.Right),
+ System.Math.Max(EdgeSpacing.Top, EdgeSpacing.Bottom)));
+
+ /// The sheet's lower-left corner in the placement frame: the translation
+ /// and were given.
+ public Vector PlacementOrigin => new(workArea.X - EdgeSpacing.Left, workArea.Y - EdgeSpacing.Bottom);
+
+ ///
+ /// Where a part may lie on an outlined sheet, in the placement frame: the outline less its
+ /// obstacles, eroded by . Null for a rectangular sheet, whose
+ /// is the answer. A fresh copy on every call.
+ ///
+ public PathsD UsableOutline() => Outline?.Usable(OutlineInset, PlacementOrigin);
+
+ ///
+ /// Whether (outer paths positive, holes negative, in the placement
+ /// frame) lies within the outlined sheet's usable area, allowing
+ /// outside it. True for a rectangular sheet: its box is checked by the callers.
+ ///
+ public bool OutlineHolds(PathsD region, double slack) =>
+ Outline == null || Outline.Holds(region, OutlineInset, PlacementOrigin, slack);
+
/// Available physical sheets: null is unlimited, zero is legal but unavailable.
public int? Quantity { get; }
public double PartSpacing { get; }
@@ -106,8 +148,9 @@ public sealed class NestPlateStock
///
public Box WorkArea => new(workArea.X, workArea.Y, workArea.Length, workArea.Width);
- /// Full sheet area before edge spacing is removed.
- public double Area => Size.Width * Size.Length;
+ /// Full sheet area before edge spacing is removed: the material inside the
+ /// when there is one.
+ public double Area => Outline?.Area ?? Size.Width * Size.Length;
/// True when a width (X) by height (Y) envelope fits the work area within epsilon.
public bool Fits(double width, double height, double epsilon = 1e-9) =>
diff --git a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs
index 4bab5c87..220d3ad0 100644
--- a/OpenNest.Engine/Jobs/Placement/PlateFillService.cs
+++ b/OpenNest.Engine/Jobs/Placement/PlateFillService.cs
@@ -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);
diff --git a/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs b/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs
index 6b42a086..80592cbd 100644
--- a/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs
+++ b/OpenNest.Engine/Jobs/RepeatedPlateGrouping.cs
@@ -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);
diff --git a/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs b/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs
index 6274a4e5..15e56f56 100644
--- a/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs
+++ b/OpenNest.Engine/NestingEngines/Circles/DiscPacker.cs
@@ -90,6 +90,8 @@ internal static class DiscPacker
/// True when the disc fits the work area in both directions.
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;
diff --git a/OpenNest.Engine/NestingEngines/FlatBar/BarCatalog.cs b/OpenNest.Engine/NestingEngines/FlatBar/BarCatalog.cs
index 216272b3..f0f46d5b 100644
--- a/OpenNest.Engine/NestingEngines/FlatBar/BarCatalog.cs
+++ b/OpenNest.Engine/NestingEngines/FlatBar/BarCatalog.cs
@@ -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()
+ : types[t].Orientations
+ .Where(o => (alongX ? o.Height : o.Width) <= crossExtent + BarPacker.OverhangAllowance)
+ .ToList();
if (fitting.Count == 0)
{
fits[t] = Array.Empty();
diff --git a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs
index 319a34e5..29d5d952 100644
--- a/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs
+++ b/OpenNest.Engine/NestingEngines/Irregular/IrregularNestingEngine.cs
@@ -149,6 +149,7 @@ public sealed class IrregularNestingEngine : INestingEngine
private const int MaxTail = 3;
private readonly Dictionary caches = new();
private readonly Dictionary clamps = new();
+ private readonly Dictionary boundaries = new();
private readonly Dictionary>> pairs = new();
private readonly Dictionary blocks = new();
private readonly IReadOnlyDictionary parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
@@ -230,6 +231,18 @@ public sealed class IrregularNestingEngine : INestingEngine
return cache;
}
+ /// 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.
+ 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)
diff --git a/OpenNest.Engine/NestingEngines/Irregular/PackBoundary.cs b/OpenNest.Engine/NestingEngines/Irregular/PackBoundary.cs
index d377c008..ff7f327d 100644
--- a/OpenNest.Engine/NestingEngines/Irregular/PackBoundary.cs
+++ b/OpenNest.Engine/NestingEngines/Irregular/PackBoundary.cs
@@ -64,6 +64,25 @@ internal sealed class PackBoundary
}
}
+ ///
+ /// A sheet's material as the boundary: is its outline less its
+ /// obstacles (outer paths positive, holes negative) in the placement frame, and parts keep
+ /// 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; is the packer's work area and front origin.
+ ///
+ 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();
+ }
+
public NoFitCache Nfps { get; }
/// Bounds of the outline: the packer's work area and front origin.
diff --git a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs
index 024ed304..3c71f8d6 100644
--- a/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs
+++ b/OpenNest.Engine/NestingEngines/Rectangles/RectanglesNestingEngine.cs
@@ -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;
diff --git a/OpenNest.Engine/NestingEngines/SheetByStock.cs b/OpenNest.Engine/NestingEngines/SheetByStock.cs
index 658f4bc6..5b60bcef 100644
--- a/OpenNest.Engine/NestingEngines/SheetByStock.cs
+++ b/OpenNest.Engine/NestingEngines/SheetByStock.cs
@@ -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)
diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md
index d1551644..d1f44aca 100644
--- a/docs/nesting-engines.md
+++ b/docs/nesting-engines.md
@@ -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,