mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-04 01:11:35 -05:00
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:
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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user