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,