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,