diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPackerProposalTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPackerProposalTests.cs
new file mode 100644
index 00000000..9068635f
--- /dev/null
+++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPackerProposalTests.cs
@@ -0,0 +1,334 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using OpenNest.CNC;
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.Jobs.Cutouts;
+using OpenNest.Engine.Tests.NestingEngines;
+using OpenNest.Geometry;
+using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
+
+namespace OpenNest.Engine.Tests.Jobs.Cutouts;
+
+/// The frontier packer as a cutout proposal: inserts settle against the cutout wall
+/// and each other instead of landing at the first sampled point that clears.
+[Collection("Cutout pipeline registry")]
+public class CutoutPackerProposalTests
+{
+ private const double Spacing = 0.3;
+
+ /// A 13.148 x 1.25 bar with a shallow notch in one long edge and eight bolt holes.
+ private const string BarCode = """
+ G91
+ G00X0Y0
+ G03X0Y0I-0.125J0
+ G00X0.5Y0
+ G03X0Y0I-0.125J0
+ G00X1.875Y0
+ G03X0Y0I-0.125J0
+ G00X0.5Y0
+ G03X0Y0I-0.125J0
+ G00X4.625Y0
+ G03X0Y0I-0.125J0
+ G00X0.5Y0
+ G03X0Y0I-0.125J0
+ G00X1.875Y0
+ G03X0Y0I-0.125J0
+ G00X0.5Y0
+ G03X0Y0I-0.125J0
+ G00X-11.8865Y-1.0625
+ G01X0Y0.75
+ G02X0.5Y0.5I0.5J0
+ G01X3.824Y0
+ G02X0.25Y-0.25I0J-0.25
+ G01X0Y-0.125
+ G03X0.25Y-0.25I0.25J-0
+ G01X3.5Y0
+ G03X0.25Y0.25I0J0.25
+ G01X0Y0.125
+ G02X0.25Y0.25I0.25J0
+ G01X3.824Y0
+ G02X0.5Y-0.5I0J-0.5
+ G01X0Y-0.75
+ G01X-13.148Y0
+ """;
+
+ /// One 12.25-wide window whose near end carries a notched tab, open to the given
+ /// wall height above the tab; the frame below stacks four such windows.
+ private static string Window(double wallHeight) => $"""
+ G01X0.125Y0
+ G01X0Y0.841458
+ G02X0.5Y0.5I0.5J0
+ G01X0Y-0.3125
+ G01X1.125Y0
+ G01X0Y0.3125
+ G01X1.25Y0
+ G01X0Y-0.3125
+ G01X1.125Y0
+ G01X0Y0.3125
+ G01X4Y0
+ G01X0Y-0.3125
+ G01X1.125Y0
+ G01X0Y0.3125
+ G01X1.25Y0
+ G01X0Y-0.3125
+ G01X1.125Y0
+ G01X0Y0.3125
+ G02X0.5Y-0.5I0J-0.5
+ G01X0Y-0.841458
+ G01X0.125Y0
+ G01X0Y{wallHeight.ToString(CultureInfo.InvariantCulture)}
+ G03X-0.125Y0.125I-0.125J0
+ G01X-12Y0
+ G03X-0.125Y-0.125I0J-0.125
+ G01X0Y-{wallHeight.ToString(CultureInfo.InvariantCulture)}
+ """;
+
+ /// A 14.82 x 69.72 frame with a plain 12.25 x 13.4 window and three tabbed windows
+ /// of rising height; only the last is tall enough to stand the bar in.
+ private static readonly string FrameCode = $"""
+ G91
+ G00X0Y0
+ G03X0Y0I-0.125J0
+ G00X0.5Y0
+ G03X0Y0I-0.125J0
+ G00X1.875Y0
+ G03X0Y0I-0.125J0
+ G00X0.5Y0
+ G03X0Y0I-0.125J0
+ G00X-3.9375Y2.033896
+ G01X12.25Y0
+ G01X0Y13.297
+ G03X-0.125Y0.125I-0.125J0
+ G01X-12Y0
+ G03X-0.125Y-0.125I0J-0.125
+ G01X0Y-13.297
+ G00X8.5625Y-2.033896
+ G03X0Y0I-0.125J0
+ G00X0.5Y0
+ G03X0Y0I-0.125J0
+ G00X1.875Y0
+ G03X0Y0I-0.125J0
+ G00X0.5Y0
+ G03X0Y0I-0.125J0
+ G00X-11.4375Y16.955896
+ {Window(14.375)}
+ G00X0Y16
+ {Window(14.375)}
+ G00X0Y16
+ {Window(19.453)}
+ G00X-1.283896Y-47.797
+ G01X0Y68.25
+ G02X0.125Y0.125I0.125J0
+ G01X14.567791Y0
+ G02X0.125Y-0.125I0J-0.125
+ G01X0Y-68.25
+ G02X-0.125Y-0.125I-0.125J0
+ G01X-0.2615Y0
+ G02X-0.125Y0.125I0J0.125
+ G01X0Y0.051
+ G01X-0.323396Y0
+ G01X0Y-0.897396
+ G02X-0.5Y-0.5I-0.5J-0
+ G01X-12.148Y0
+ G02X-0.5Y0.5I0J0.5
+ G01X0Y0.897396
+ G01X-0.323396Y0
+ G01X0Y-0.051
+ G02X-0.125Y-0.125I-0.125J0
+ G01X-0.2615Y0
+ G02X-0.125Y0.125I0J0.125
+ """;
+
+ /// Height of the tab on a tabbed window's near end.
+ private const double TabHeight = 0.841458 + 0.5;
+
+ private static Program Bar() => Incremental(BarCode);
+ private static Program Frame() => Incremental(FrameCode);
+
+ [Fact]
+ public void FrameHasFourWindowsAndTheBarIsSolid()
+ {
+ var frame = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Frame()));
+ var bar = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Bar()));
+ Assert.Equal(4, frame.Cutouts.Count(c => System.Math.Abs(c.Area()) > 100));
+ Assert.Equal(14.818, frame.Bounds.Length, 3);
+ Assert.Equal(69.721, frame.Bounds.Width, 3);
+ Assert.Equal(13.148, bar.Bounds.Length, 3);
+ Assert.Equal(1.25, bar.Bounds.Width, 3);
+ }
+
+ [Fact]
+ public void TwoBarsStackAgainstAWindowWallInsteadOfScatteringAcrossIt()
+ {
+ var frame = JobBuilder.Part("PT10", Frame(), 1);
+ var bar = JobBuilder.Part("PT11", Bar(), 2);
+ var geometry = JobPartGeometry.Read(frame.Geometry);
+ var seated = new List<(int Hole, IReadOnlyList Poses)>();
+ for (var hole = 0; hole < geometry.Cutouts.Count; hole++)
+ {
+ if (System.Math.Abs(geometry.Cutouts[hole].Area()) < 100)
+ continue;
+ var poses = CutoutRouter.Fill(frame, hole, new[] { bar }, Spacing);
+ if (poses.Count == 2)
+ seated.Add((hole, poses));
+ }
+ var (window, stacked) = Assert.Single(seated);
+ AssertClear(frame, bar, stacked, Spacing);
+ AssertStacked(geometry.Cutouts[window], JobPartGeometry.Read(bar.Geometry), stacked);
+ }
+
+ [Fact]
+ public void PackerAloneSeatsBothBarsAgainstTheWall()
+ {
+ var frame = JobBuilder.Part("PT10", Frame(), 1);
+ var bar = JobBuilder.Part("PT11", Bar(), 2);
+ var geometry = JobPartGeometry.Read(frame.Geometry);
+ var tallest = Enumerable.Range(0, geometry.Cutouts.Count)
+ .OrderByDescending(i => System.Math.Abs(geometry.Cutouts[i].Area())).First();
+ var poses = CutoutPackerProposal.Fill(frame, tallest, new[] { bar }, Spacing);
+ Assert.Equal(2, poses.Count);
+ Assert.Equal(new[] { 0, 1 }, poses.Select(p => p.InstanceIndex));
+ AssertClear(frame, bar, poses, Spacing);
+ AssertStacked(geometry.Cutouts[tallest], JobPartGeometry.Read(bar.Geometry), poses);
+ }
+
+ [Fact]
+ public void PackerKeepsClearOfInsertsAlreadySeated()
+ {
+ var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
+ var insert = JobBuilder.Rectangle("insert", 3, 2, 6);
+ var geometry = JobPartGeometry.Read(insert.Geometry);
+ var occupant = new NestJobPlacement("occupant", 0, -1.5, -1, 0);
+ var poses = CutoutPackerProposal.Fill(frame, 0, new[] { insert }, 0.25,
+ new[] { (geometry, occupant) }, null, default);
+ Assert.NotEmpty(poses);
+ foreach (var pose in poses)
+ Assert.True(NestLayoutCheck.Clears(geometry, occupant, geometry, pose, 0.25), $"{pose} meets the occupant");
+ AssertClear(frame, insert, poses, 0.25);
+ }
+
+ [Theory]
+ [InlineData("Irregular")]
+ [InlineData("Default")]
+ public void AutoNestSeatsBothBarsStackedInOneWindow(string engine)
+ {
+ var frameDrawing = new Drawing("PT10", Frame());
+ var barDrawing = new Drawing("PT11", Bar());
+ var request = new NestPipelineRequest(engine,
+ new[] { new NestItem { Drawing = barDrawing, Quantity = 2 }, new NestItem { Drawing = frameDrawing, Quantity = 2 } },
+ new[] { new NestPlateStock("sheet", new Size(60, 120), 1, Spacing) },
+ new NestJobOptions(maxPlates: 1));
+ var result = NestPipeline.Run(request);
+ Assert.True(result.IsValid, string.Join("; ", result.Violations));
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ var plate = Assert.Single(result.Plates);
+ var bars = plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, barDrawing)).ToArray();
+ var frames = plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, frameDrawing)).ToArray();
+ Assert.Equal(2, bars.Length);
+ Assert.Equal(2, frames.Length);
+ var frameGeometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Frame()));
+ var barGeometry = JobPartGeometry.Read(PartGeometrySnapshot.FromProgram(Bar()));
+ // Both bars lie in one window of one frame; express them in that frame's coordinates.
+ var seated = new List<(Part Frame, Shape Window, IReadOnlyList Poses)>();
+ foreach (var host in frames)
+ {
+ var local = bars.Select(bar => Local(host, bar)).ToArray();
+ foreach (var window in frameGeometry.Cutouts.Where(c => System.Math.Abs(c.Area()) > 100))
+ if (local.All(pose => Inside(window, Extent(barGeometry, pose))))
+ seated.Add((host, window, local));
+ }
+ var (_, hole, poses) = Assert.Single(seated);
+ AssertStacked(hole, barGeometry, poses);
+ }
+
+ private static NestJobPlacement Local(Part frame, Part part)
+ {
+ var dx = part.Location.X - frame.Location.X;
+ var dy = part.Location.Y - frame.Location.Y;
+ var cosine = System.Math.Cos(-frame.Rotation);
+ var sine = System.Math.Sin(-frame.Rotation);
+ return new NestJobPlacement("PT11", 0, dx * cosine - dy * sine, dx * sine + dy * cosine,
+ part.Rotation - frame.Rotation);
+ }
+
+ private static bool Inside(Shape window, Box box)
+ {
+ var bounds = window.BoundingBox;
+ return box.Left > bounds.Left && box.Right < bounds.Right && box.Bottom > bounds.Bottom && box.Top < bounds.Top;
+ }
+
+ /// Both bars stand in the window against one wall and against each other, not in
+ /// the middle: each touches the wall or the other bar across the window, and the near tab
+ /// or the far wall along it, within the spacing plus a small tolerance.
+ private static void AssertStacked(Shape window, JobPartGeometry bar, IReadOnlyList poses)
+ {
+ const double slack = 0.05;
+ var hole = window.BoundingBox;
+ var boxes = poses.Select(p => Extent(bar, p)).ToArray();
+ Assert.Equal(2, boxes.Length);
+ foreach (var box in boxes)
+ {
+ Assert.True(Inside(window, box), $"{box} is not inside the window {hole}");
+ var other = boxes.Single(b => !ReferenceEquals(b, box));
+ var toWall = System.Math.Min(box.Left - hole.Left, hole.Right - box.Right);
+ var toOther = System.Math.Max(other.Left - box.Right, box.Left - other.Right);
+ Assert.True(toWall <= Spacing + slack || toOther <= Spacing + slack,
+ $"{box} floats {toWall:F3} from the walls and {toOther:F3} from the other bar");
+ var toEnd = System.Math.Min(box.Bottom - hole.Bottom - TabHeight, hole.Top - box.Top);
+ Assert.True(toEnd <= Spacing + slack, $"{box} floats {toEnd:F3} from the window ends");
+ }
+ var gap = System.Math.Max(boxes[1].Left - boxes[0].Right, boxes[0].Left - boxes[1].Right);
+ Assert.InRange(gap, Spacing - 1e-6, Spacing + slack);
+ var center = (boxes.Min(b => b.Left) + boxes.Max(b => b.Right)) / 2;
+ Assert.True(System.Math.Abs(center - (hole.Left + hole.Right) / 2) > 2,
+ "the pair sits in the middle of the window");
+ }
+
+ private static Box Extent(JobPartGeometry geometry, NestJobPlacement pose)
+ {
+ var shape = (Shape)geometry.Perimeter.Clone();
+ shape.Rotate(pose.Rotation);
+ var box = shape.BoundingBox;
+ return new Box(box.Left + pose.X, box.Bottom + pose.Y, box.Length, box.Width);
+ }
+
+ private static void AssertClear(NestJobPart frame, NestJobPart insert, IReadOnlyList poses,
+ double spacing)
+ {
+ var frameGeometry = JobPartGeometry.Read(frame.Geometry);
+ var geometry = JobPartGeometry.Read(insert.Geometry);
+ var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
+ foreach (var pose in poses)
+ Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, spacing), $"{pose} meets the frame");
+ for (var i = 0; i < poses.Count; i++)
+ for (var j = i + 1; j < poses.Count; j++)
+ Assert.True(NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing),
+ $"{poses[i]} meets {poses[j]}");
+ }
+
+ /// Incremental (G91) G-code: rapids, lines and arcs with centers relative to the start.
+ private static Program Incremental(string code)
+ {
+ var program = new Program();
+ double x = 0, y = 0;
+ foreach (var line in code.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ var words = Regex.Matches(line, @"([A-Z])(-?\d*\.?\d+)")
+ .ToDictionary(m => m.Groups[1].Value[0], m => double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture));
+ var g = (int)words['G'];
+ if (g == 91)
+ continue;
+ var end = new Vector(x + words.GetValueOrDefault('X'), y + words.GetValueOrDefault('Y'));
+ var center = new Vector(x + words.GetValueOrDefault('I'), y + words.GetValueOrDefault('J'));
+ program.Codes.Add(g switch
+ {
+ 0 => new RapidMove(end.X, end.Y),
+ 1 => new LinearMove(end.X, end.Y),
+ 2 => new ArcMove(end, center, RotationType.CW),
+ _ => new ArcMove(end, center, RotationType.CCW),
+ });
+ (x, y) = (end.X, end.Y);
+ }
+ return program;
+ }
+}
diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs
index 81a54385..d1bfa51d 100644
--- a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs
+++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs
@@ -12,9 +12,10 @@ public sealed class CutoutPipelineRegistryCollection { }
[Collection("Cutout pipeline registry")]
public class CutoutPipelinePreviewTests
{
- private static NestPipelineRequest Request(string engine = "Irregular")
+ /// Hole diameter: ten holds two 3 x 3 inserts, six holds exactly one.
+ private static NestPipelineRequest Request(string engine = "Irregular", double hole = 10)
{
- var frame = new Drawing("frame", TestShapes.Ring(20, 10));
+ var frame = new Drawing("frame", TestShapes.Ring(20, hole));
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
return new NestPipelineRequest(engine,
new[] { new NestItem { Drawing = frame, Quantity = 1 },
@@ -112,7 +113,8 @@ public class CutoutPipelinePreviewTests
[Fact]
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
{
- var request = Request("stub");
+ // One insert is reserved in the hole; the other must stay an ordinary requirement.
+ var request = Request("stub", hole: 6);
request.Items[1].Quantity = 2;
request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } };
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
@@ -171,7 +173,8 @@ public class CutoutPipelinePreviewTests
[Fact]
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
{
- var request = Request("stub");
+ // One insert is reserved in the hole; the other must stay an ordinary requirement.
+ var request = Request("stub", hole: 6);
request.Items[1].Quantity = 2;
var result = NestPipeline.RunCutoutTrial(new Stub(job =>
{
@@ -273,7 +276,8 @@ public class CutoutPipelinePreviewTests
[Fact]
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
{
- var initial = Request("stub");
+ // One hole seat: the first requirement row fills it, the second row stays separate.
+ var initial = Request("stub", hole: 6);
var request = initial with
{
Items = new[] { initial.Items[0], initial.Items[1],
diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPackerProposal.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPackerProposal.cs
new file mode 100644
index 00000000..436788e3
--- /dev/null
+++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPackerProposal.cs
@@ -0,0 +1,203 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Clipper2Lib;
+using OpenNest.Engine.NestingEngines.Irregular;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.Jobs.Cutouts;
+
+///
+/// Frame-local cutout proposal that packs one closed cutout with the Irregular engine's
+/// , taking the cutout as the packing boundary and every insert
+/// requirement at once: parts settle against the cutout wall and each other instead of at the
+/// first sampled point that clears. Each of the engine's strategy variants packs the hole; the
+/// variant seating the most copies wins, ties going to the tighter layout. Every pose is then
+/// certified with against the frame, the occupants and the
+/// other accepted poses, exactly as the lattice and NFP proposals are. Never changes a job.
+///
+internal static class CutoutPackerProposal
+{
+ private const double MaxCoordinate = 1e8;
+ private const double MaxSpacing = 100;
+ private const double Tie = 1e-6;
+
+ internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex,
+ IReadOnlyList inserts, double spacing, CancellationToken token = default) =>
+ Fill(frame, cutoutIndex, inserts, spacing,
+ Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), null, token);
+
+ /// Poses in the frame's own coordinates, in packing order, clearing the frame,
+ /// the inserts and each other; instance indices count per
+ /// requirement.
+ internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex,
+ IReadOnlyList inserts, double spacing,
+ IReadOnlyList<(JobPartGeometry Geometry, NestJobPlacement Pose)> occupied, CutoutMemo? memo,
+ CancellationToken token)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ ArgumentNullException.ThrowIfNull(inserts);
+ ArgumentNullException.ThrowIfNull(occupied);
+ token.ThrowIfCancellationRequested();
+ if (inserts.Count == 0 || !double.IsFinite(spacing) || spacing < 0 || spacing > MaxSpacing)
+ return Array.Empty();
+ var frameGeometry = memo?.Read(frame.Geometry) ?? JobPartGeometry.TryRead(frame.Geometry);
+ if (frameGeometry == null || !WithinRange(frameGeometry.Bounds))
+ return Array.Empty();
+ ArgumentOutOfRangeException.ThrowIfNegative(cutoutIndex);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(cutoutIndex, frameGeometry.Cutouts.Count);
+ var cutout = frameGeometry.Cutouts[cutoutIndex];
+ if (!cutout.IsClosed() || !WithinRange(cutout.BoundingBox)
+ || spacing * 2 >= System.Math.Min(cutout.BoundingBox.Length, cutout.BoundingBox.Width))
+ return Array.Empty();
+ var geometries = new Dictionary(StringComparer.Ordinal);
+ foreach (var insert in inserts)
+ {
+ var geometry = memo?.Read(insert.Geometry) ?? JobPartGeometry.TryRead(insert.Geometry);
+ if (geometry == null || !WithinRange(geometry.Bounds))
+ return Array.Empty();
+ geometries[insert.Id] = geometry;
+ }
+
+ // Inscribed: the flattened cutout never extends past the real one.
+ var hole = CutoutLatticeFill.Positive(ClipperBridge.ToPath(
+ ClipperBridge.Flatten(cutout, CutoutLatticeFill.FlattenTolerance, circumscribe: false), new Vector()));
+ if (hole.Count < 3)
+ return Array.Empty();
+ var box = cutout.BoundingBox;
+ var stock = new NestPlateStock("cutout", new Size(box.Width, box.Length), 1, spacing);
+ var types = PartCatalog.Build(new NestJob(inserts, new[] { stock }));
+ if (types.All(t => t.Orientations.Count == 0))
+ return Array.Empty();
+ token.ThrowIfCancellationRequested();
+
+ var nfps = new NoFitCache(spacing);
+ var obstacles = occupied.Select(o => Outline(o.Geometry, o.Pose)).ToList();
+ var boundary = new PackBoundary(hole, obstacles, nfps);
+ var pairs = new Dictionary>();
+ var counter = new WorkCounter();
+ var demand = types.Select(t => t.Part.Quantity).ToArray();
+ var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
+ var preparedFrame = NestLayoutCheck.Prepare(frameGeometry, framePose, spacing);
+ var preparedOccupied = new NestLayoutCheck.PreparedPlacement[occupied.Count];
+ for (var i = 0; i < occupied.Count; i++)
+ {
+ token.ThrowIfCancellationRequested();
+ preparedOccupied[i] = NestLayoutCheck.Prepare(occupied[i].Geometry, occupied[i].Pose, spacing);
+ }
+
+ List? best = null;
+ foreach (var (axis, beta) in IrregularNestingEngine.Variants)
+ {
+ token.ThrowIfCancellationRequested();
+ var packer = new FrontierPacker(types, nfps, pairs, stock, axis, beta, counter, boundary: boundary);
+ var fill = packer.Fill(demand, token);
+ var poses = new List(fill.Parts.Count);
+ foreach (var placed in fill.Parts)
+ {
+ token.ThrowIfCancellationRequested();
+ var part = types[placed.Orientation.TypeIndex].Part;
+ var pose = new NestJobPlacement(part.Id, 0, System.Math.Round(placed.X, 8),
+ System.Math.Round(placed.Y, 8), placed.Orientation.Rotation);
+ if (Clears(pose, geometries[part.Id], preparedFrame, preparedOccupied, poses, geometries, spacing, memo, token))
+ poses.Add(pose);
+ }
+ if (poses.Count == 0)
+ continue;
+ if (best == null || poses.Count > best.Count
+ || (poses.Count == best.Count && Tighter(poses, best, id => geometries[id])))
+ best = poses;
+ }
+ token.ThrowIfCancellationRequested();
+ if (best == null)
+ return Array.Empty();
+ var counts = new Dictionary(StringComparer.Ordinal);
+ return best.Select(p =>
+ {
+ var index = counts.GetValueOrDefault(p.PartId);
+ counts[p.PartId] = index + 1;
+ return p with { InstanceIndex = index };
+ }).ToArray();
+ }
+
+ /// True when is the more compact layout: a smaller
+ /// bounding box of the placed inserts, then one lower, then one further left.
+ internal static bool Tighter(IReadOnlyList candidate,
+ IReadOnlyList incumbent, Func geometry)
+ {
+ if (candidate.Count == 0)
+ return false;
+ if (incumbent.Count == 0)
+ return true;
+ var a = Envelope(candidate, geometry);
+ var b = Envelope(incumbent, geometry);
+ var areaA = a.Length * a.Width;
+ var areaB = b.Length * b.Width;
+ if (System.Math.Abs(areaA - areaB) > Tie * System.Math.Max(1, System.Math.Max(areaA, areaB)))
+ return areaA < areaB;
+ if (System.Math.Abs(a.Bottom - b.Bottom) > Tie)
+ return a.Bottom < b.Bottom;
+ return a.Left < b.Left - Tie;
+ }
+
+ private static Box Envelope(IReadOnlyList poses, Func geometry)
+ {
+ var left = double.PositiveInfinity;
+ var bottom = double.PositiveInfinity;
+ var right = double.NegativeInfinity;
+ var top = double.NegativeInfinity;
+ foreach (var pose in poses)
+ {
+ var shape = (Shape)geometry(pose.PartId).Perimeter.Clone();
+ shape.Rotate(pose.Rotation);
+ var bounds = shape.BoundingBox;
+ left = System.Math.Min(left, bounds.Left + pose.X);
+ bottom = System.Math.Min(bottom, bounds.Bottom + pose.Y);
+ right = System.Math.Max(right, bounds.Right + pose.X);
+ top = System.Math.Max(top, bounds.Top + pose.Y);
+ }
+ return new Box(left, bottom, right - left, top - bottom);
+ }
+
+ private static bool Clears(NestJobPlacement pose, JobPartGeometry geometry,
+ NestLayoutCheck.PreparedPlacement frame, NestLayoutCheck.PreparedPlacement[] occupied,
+ List accepted, IReadOnlyDictionary geometries,
+ double spacing, CutoutMemo? memo, CancellationToken token)
+ {
+ foreach (var other in occupied)
+ {
+ token.ThrowIfCancellationRequested();
+ if (!NestLayoutCheck.Clears(other, geometry, pose))
+ return false;
+ }
+ foreach (var other in accepted)
+ {
+ token.ThrowIfCancellationRequested();
+ var otherGeometry = geometries[other.PartId];
+ var clears = memo?.Clears(otherGeometry, other, geometry, pose, spacing)
+ ?? NestLayoutCheck.Clears(otherGeometry, other, geometry, pose, spacing);
+ if (!clears)
+ return false;
+ }
+ token.ThrowIfCancellationRequested();
+ return NestLayoutCheck.Clears(frame, geometry, pose);
+ }
+
+ /// The occupant's perimeter at its pose, circumscribed so it never loses material.
+ private static (PathD Outline, double Tolerance) Outline(JobPartGeometry geometry, NestJobPlacement pose)
+ {
+ var shape = (Shape)geometry.Perimeter.Clone();
+ shape.Rotate(pose.Rotation);
+ var path = ClipperBridge.ToPath(ClipperBridge.Flatten(shape, CutoutLatticeFill.FlattenTolerance, circumscribe: true),
+ new Vector(pose.X, pose.Y));
+ return (CutoutLatticeFill.Positive(path), CutoutLatticeFill.FlattenTolerance);
+ }
+
+ private static bool WithinRange(Box box) =>
+ double.IsFinite(box.Left) && double.IsFinite(box.Right)
+ && double.IsFinite(box.Bottom) && double.IsFinite(box.Top)
+ && System.Math.Abs(box.Left) <= MaxCoordinate && System.Math.Abs(box.Right) <= MaxCoordinate
+ && System.Math.Abs(box.Bottom) <= MaxCoordinate && System.Math.Abs(box.Top) <= MaxCoordinate;
+}
diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs
index 2e4dbc08..e86fc3b5 100644
--- a/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs
+++ b/OpenNest.Engine/Jobs/Cutouts/CutoutRouter.cs
@@ -10,7 +10,8 @@ namespace OpenNest.Engine.Jobs.Cutouts;
/// Area ratio selects search order, not placement permission. The small-insert path
/// prefers a shifted partial lattice; the middle compares it with occupied-aware NFP search;
/// the large-insert path tries NFP first. A round hole whose inserts are all round gets only the
-/// circle-in-circle proposal for the whole list.
+/// circle-in-circle proposal for the whole list. Every other hole is also packed by
+/// , which wins with more copies or an equal count packed tighter.
/// All accepted poses are clearance-checked.
/// Fill's equal-score pose nondeterminism remains PM c98c21bd.
internal static class CutoutRouter
@@ -32,7 +33,8 @@ internal static class CutoutRouter
Fill(frame, cutoutIndex, inserts, spacing, null, memo, token);
/// Optional internal diagnostic receives only poses from the chosen proposal,
- /// after indexing; true marks a retained lattice seed, false a residual NFP or circle-packer pose.
+ /// after indexing; true marks a retained lattice seed, false a residual NFP, circle-packer or
+ /// frontier-packer pose.
internal static IReadOnlyList Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList inserts, double spacing,
Action? observeAccepted, CancellationToken token = default) =>
@@ -66,9 +68,10 @@ internal static class CutoutRouter
// NFP searches never placed more there, and they are slow on round inserts, most of all
// when demand exceeds what fits (every surplus copy is searched for and not found).
if (CutoutCirclePacker.Applies(hole, inserts))
- return Circles(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token));
+ return Observe(CutoutCirclePacker.Fill(frame, cutoutIndex, inserts, spacing, memo, token));
var accepted = new List();
+ var geometries = new Dictionary(StringComparer.Ordinal);
var observed = new List<(NestJobPlacement Pose, bool Lattice)>();
var occupied = new List<(JobPartGeometry Geometry, NestJobPlacement Pose)>();
var holeArea = System.Math.Abs(hole.Area());
@@ -77,6 +80,7 @@ internal static class CutoutRouter
{
token.ThrowIfCancellationRequested();
var geometry = JobPartGeometry.Read(part.Geometry);
+ geometries[part.Id] = geometry;
var ratio = geometry.MaterialArea / holeArea;
// Unsupported or degenerate material cannot establish a useful routing ratio;
// NFP retains its own fail-closed checks and the bounded lattice is not tried.
@@ -148,11 +152,20 @@ internal static class CutoutRouter
}
}
token.ThrowIfCancellationRequested();
+ // The frontier packer sees every requirement at once and settles parts against the
+ // walls and each other; it replaces the sampled proposal when it seats more copies, or
+ // the same number more compactly.
+ var packed = CutoutPackerProposal.Fill(frame, cutoutIndex, inserts, spacing,
+ Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), memo, token);
+ if (packed.Count > accepted.Count
+ || (packed.Count == accepted.Count
+ && CutoutPackerProposal.Tighter(packed, accepted, id => geometries[id])))
+ return Observe(packed);
foreach (var (pose, lattice) in observed)
observeAccepted?.Invoke(pose, lattice);
return accepted.ToArray();
- IReadOnlyList Circles(IReadOnlyList poses)
+ IReadOnlyList Observe(IReadOnlyList poses)
{
foreach (var pose in poses)
observeAccepted?.Invoke(pose, false);
diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md
index 204bc3f9..d1551644 100644
--- a/docs/nesting-engines.md
+++ b/docs/nesting-engines.md
@@ -91,8 +91,9 @@ Curved outlines retain conservative flattening-error padding. This does not rela
footprints, no-fit polygons or layout validation tolerances; a part extending beyond the accepted
work-area bounds remains invalid.
-Irregular fills gaps and open notches using outer profiles; it does not yet place parts inside
-enclosed cutouts. For a part with two or more copies it also offers its best-fit pairs (two copies
+Irregular fills gaps and open notches using outer profiles; as an engine it does not place parts
+inside enclosed cutouts, but its packer also fills one cutout for the pre-pass described under
+[Filling cutouts](#filling-cutouts). For a part with two or more copies it also offers its best-fit pairs (two copies
interlocked, as the Best Fit viewer shows them) alongside the single copies, and places a pair
where both members' free regions allow it. Each pair's internal spacing is re-checked with the
layout check before it is offered, and only rotations the part's policy allows are used. A pair
@@ -291,6 +292,25 @@ An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole.
Six geometry-only, anonymized real-job probes and neutral ring fixtures informed
the search-order hints; these limited cases are not a global density guarantee.
+Every such hole is also packed by `CutoutPackerProposal`: the Irregular engine's
+`FrontierPacker` run with the cutout as its boundary (`PackBoundary`) in place of a rectangular
+work area. Each insert orientation's free region starts from its inner-fit polygon of the
+inscribed, flattened cutout eroded by half the spacing (the orientation's clearance footprint
+carries the other half, so the frame keeps the full spacing exactly as another part would), less
+the no-fit polygons of inserts already seated in the hole; the inner fits are computed once per
+orientation per boundary. The packer then places every offered requirement at once under its
+usual gap-fill and least-advance rule, so copies settle against the wall and each other instead
+of at the first sampled point that clears. All six strategy variants pack the hole; the one
+seating the most copies wins, and ties go to the tighter layout (smaller bounding box of the
+placed inserts, then lower, then further left). Every pose is certified with
+`NestLayoutCheck.Clears` against the frame, the occupants and the other poses, as the lattice
+and NFP poses are; no tolerance is loosened. The router keeps the packer's proposal when it
+seats more copies than the lattice-and-NFP proposal, or the same number more compactly. Two
+bars with quantity two, which the lattice never tries and the sampled NFP search scattered
+across a tall window, now stand together against one wall. Without a boundary the packer is
+unchanged: rectangular stock still starts from the work-area inner-fit rectangle, so engine
+layouts are identical.
+
When the cutout is one circle and every insert fills at least 0.8 of its enclosing circle
(discs, washers, gears by their tip circle), `CutoutCirclePacker` first packs the whole insert
list as discs: largest first, at centers tangent to two of the hole wall and the placed discs,