feat(cutouts): pack a cutout with the frontier packer

CutoutPackerProposal runs the Irregular engine's FrontierPacker on one
hole, frame-local, with the hole as its boundary and already seated
inserts as obstacles, for all six strategy variants; every pose is
certified with NestLayoutCheck.Clears against the frame, the occupants
and the other poses, and the variant seating the most copies wins, ties
going to the tighter layout. CutoutRouter keeps that proposal over the
lattice-and-NFP one when it seats more copies, or the same number more
compactly, so the pre-pass gets it on every non-round hole.

Two copies of a bar in a frame window, which the lattice never tried and
the sampled NFP search scattered across the window (one at the inner
fit's centre), now stand together against one wall. The test rebuilds
that frame and bar from embedded G-code; the residual-accounting
pipeline tests use a hole that holds one insert so their second copy
stays an ordinary requirement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Fable 5.1 committed 2026-10-28 17:44:08 -04:00
1 parent f22e839850
commit e5dc67346d
5 files changed
+585 -11

No files matched your search

@@ -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;
/// <summary>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.</summary>
[Collection("Cutout pipeline registry")]
public class CutoutPackerProposalTests
{
private const double Spacing = 0.3;
/// <summary>A 13.148 x 1.25 bar with a shallow notch in one long edge and eight bolt holes.</summary>
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
""";
/// <summary>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.</summary>
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)}
""";
/// <summary>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.</summary>
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
""";
/// <summary>Height of the tab on a tabbed window's near end.</summary>
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<NestJobPlacement> 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<NestJobPlacement> 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;
}
/// <summary>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.</summary>
private static void AssertStacked(Shape window, JobPartGeometry bar, IReadOnlyList<NestJobPlacement> 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<NestJobPlacement> 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]}");
}
/// <summary>Incremental (G91) G-code: rapids, lines and arcs with centers relative to the start.</summary>
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;
}
}
@@ -12,9 +12,10 @@ public sealed class CutoutPipelineRegistryCollection { }
[Collection("Cutout pipeline registry")]
public class CutoutPipelinePreviewTests
{
private static NestPipelineRequest Request(string engine = "Irregular")
/// <param name="hole">Hole diameter: ten holds two 3 x 3 inserts, six holds exactly one.</param>
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],
@@ -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;
/// <summary>
/// Frame-local cutout proposal that packs one closed cutout with the Irregular engine's
/// <see cref="FrontierPacker"/>, 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 <see cref="NestLayoutCheck.Clears"/> against the frame, the occupants and the
/// other accepted poses, exactly as the lattice and NFP proposals are. Never changes a job.
/// </summary>
internal static class CutoutPackerProposal
{
private const double MaxCoordinate = 1e8;
private const double MaxSpacing = 100;
private const double Tie = 1e-6;
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing, CancellationToken token = default) =>
Fill(frame, cutoutIndex, inserts, spacing,
Array.Empty<(JobPartGeometry Geometry, NestJobPlacement Pose)>(), null, token);
/// <summary>Poses in the frame's own coordinates, in packing order, clearing the frame,
/// the <paramref name="occupied"/> inserts and each other; instance indices count per
/// requirement.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> 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<NestJobPlacement>();
var frameGeometry = memo?.Read(frame.Geometry) ?? JobPartGeometry.TryRead(frame.Geometry);
if (frameGeometry == null || !WithinRange(frameGeometry.Bounds))
return Array.Empty<NestJobPlacement>();
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<NestJobPlacement>();
var geometries = new Dictionary<string, JobPartGeometry>(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<NestJobPlacement>();
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<NestJobPlacement>();
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<NestJobPlacement>();
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<int, IReadOnlyList<PairPose>>();
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<NestJobPlacement>? 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<NestJobPlacement>(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<NestJobPlacement>();
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
return best.Select(p =>
{
var index = counts.GetValueOrDefault(p.PartId);
counts[p.PartId] = index + 1;
return p with { InstanceIndex = index };
}).ToArray();
}
/// <summary>True when <paramref name="candidate"/> is the more compact layout: a smaller
/// bounding box of the placed inserts, then one lower, then one further left.</summary>
internal static bool Tighter(IReadOnlyList<NestJobPlacement> candidate,
IReadOnlyList<NestJobPlacement> incumbent, Func<string, JobPartGeometry> 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<NestJobPlacement> poses, Func<string, JobPartGeometry> 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<NestJobPlacement> accepted, IReadOnlyDictionary<string, JobPartGeometry> 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);
}
/// <summary>The occupant's perimeter at its pose, circumscribed so it never loses material.</summary>
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;
}
+17 -4
View File
@@ -10,7 +10,8 @@ namespace OpenNest.Engine.Jobs.Cutouts;
/// <remarks>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
/// <see cref="CutoutPackerProposal"/>, 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.</remarks>
internal static class CutoutRouter
@@ -32,7 +33,8 @@ internal static class CutoutRouter
Fill(frame, cutoutIndex, inserts, spacing, null, memo, token);
/// <summary>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.</summary>
/// after indexing; true marks a retained lattice seed, false a residual NFP, circle-packer or
/// frontier-packer pose.</summary>
internal static IReadOnlyList<NestJobPlacement> Fill(NestJobPart frame, int cutoutIndex,
IReadOnlyList<NestJobPart> inserts, double spacing,
Action<NestJobPlacement, bool>? 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<NestJobPlacement>();
var geometries = new Dictionary<string, JobPartGeometry>(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<NestJobPlacement> Circles(IReadOnlyList<NestJobPlacement> poses)
IReadOnlyList<NestJobPlacement> Observe(IReadOnlyList<NestJobPlacement> poses)
{
foreach (var pose in poses)
observeAccepted?.Invoke(pose, false);
+22 -2
View File
@@ -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,