diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelineHoledInsertTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelineHoledInsertTests.cs new file mode 100644 index 00000000..7324390a --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelineHoledInsertTests.cs @@ -0,0 +1,89 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.Jobs.Cutouts; + +/// An insert with holes of its own goes into a frame's cutout when none of its holes +/// fits any part of the job (bolt holes in a strap); otherwise it stays independent, since a +/// later insert could land in its hole. +[Collection("Cutout pipeline registry")] +public class CutoutPipelineHoledInsertTests +{ + private sealed class Stub(Func solve) : INestingEngine + { + public List Jobs { get; } = new(); + + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + Jobs.Add(job); + return solve(job); + } + } + + // A 20 x 20 frame with a 12 x 14 window. + private static Program Frame() => WithSquareHoles(TestShapes.Rectangle(20, 20), (4, 3, 12, 14)); + + // A 13 x 1.25 strap with two 0.25 bolt holes: it fits the window only upright. + private static Program Strap() + { + var program = TestShapes.Rectangle(13, 1.25); + foreach (var x in new[] { 1.0, 12.0 }) + { + program.Codes.Add(new RapidMove(x + 0.125, 0.625)); + program.Codes.Add(new ArcMove(x - 0.125, 0.625, x, 0.625, RotationType.CCW)); + program.Codes.Add(new ArcMove(x + 0.125, 0.625, x, 0.625, RotationType.CCW)); + } + return program; + } + + private static Program WithSquareHoles(Program program, params (double X, double Y, double W, double H)[] holes) + { + foreach (var (x, y, w, h) in holes) + { + program.Codes.Add(new RapidMove(x, y)); + program.Codes.Add(new LinearMove(x + w, y)); + program.Codes.Add(new LinearMove(x + w, y + h)); + program.Codes.Add(new LinearMove(x, y + h)); + program.Codes.Add(new LinearMove(x, y)); + } + return program; + } + + private static NestPipelineRequest Request(string engine, params Program[] programs) => + new(engine, + programs.Select((p, i) => new NestItem { Drawing = new Drawing($"d{i}", p), Quantity = 1 }).ToArray(), + // Only the frame fits beside nothing else: the strap must go in its window. + new[] { new NestPlateStock("sheet", new Size(20.5, 20.5), 1, 0.25) }); + + [Theory] + [InlineData("Irregular")] + [InlineData("Default")] + public void StrapWithBoltHolesGoesInTheFrameWindow(string engine) + { + var request = Request(engine, Frame(), Strap()); + Assert.Equal(NestJobStatus.Incomplete, NestPipeline.RunWithoutCutouts(request).Status); + + var result = NestPipeline.Run(request); + + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(new[] { request.Items[1].Drawing, request.Items[0].Drawing }, + Assert.Single(result.Plates).Parts.Select(p => p.BaseDrawing)); + } + + [Fact] + public void InsertWhoseHoleFitsAnotherPartStaysIndependent() + { + // The 10 x 10 part's 4 x 4 hole fits the 2 x 2 part, so it is never reserved in the + // frame's window; the 2 x 2 part goes into that hole, the smaller frame, instead. + var holed = WithSquareHoles(TestShapes.Rectangle(10, 10), (3, 3, 4, 4)); + var engine = new Stub(job => new NestJobResultBuilder(job).Build(NestJobStopReason.NoPlacementFound)); + NestPipeline.RunCutoutTrial(engine, "stub", Request("stub", Frame(), holed, TestShapes.Rectangle(2, 2))); + + Assert.Equal(new[] { "part-0", "__cutout-proxy-part-1-0" }, + Assert.Single(engine.Jobs).Parts.Select(p => p.Id)); + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs index 3eb15d99..75a07de4 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -57,6 +57,12 @@ internal sealed class CutoutPipelinePrepass // An insert's outline cannot fit a hole of smaller area; bolt holes skip the searches. var outline = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g ? System.Math.Abs(g.Perimeter.Area()) : double.PositiveInfinity); + // Lattice and NFP placement read a part's holes as free space, so they take as inserts + // only parts whose holes no part of the job fits by area: no later insert can land in + // one. Proxies are never solid; their frames' holes hold reserved inserts. + var smallestOutline = outline.Values.DefaultIfEmpty(double.PositiveInfinity).Min(); + var solid = original.Parts.ToDictionary(p => p.Id, p => geometry[p.Id] is { } g + && g.Cutouts.All(c => System.Math.Abs(c.Area()) < smallestOutline)); var bundles = new Dictionary(StringComparer.Ordinal); var byFrame = new Dictionary>(StringComparer.Ordinal); // A proposal depends on the frame, the hole and the offered inserts with their local @@ -104,10 +110,10 @@ internal sealed class CutoutPipelinePrepass .Select(p => new NestJobPart(p.Id, p.Geometry, available[p.Id], p.Priority, LocalRotation(p.Rotation, angle))) .ToArray(); - var flat = eligible.Where(p => geometry[p.Id] is { Cutouts.Count: 0 }).ToArray(); + var flat = eligible.Where(p => solid.GetValueOrDefault(p.Id)).ToArray(); var nested = CutoutCirclePacker.RoundHole(profile.Cutouts[hole]) == null ? Array.Empty() - : eligible.Where(p => round[p.Id] && geometry[p.Id]!.Cutouts.Count > 0).ToArray(); + : eligible.Where(p => round[p.Id] && !solid.GetValueOrDefault(p.Id)).ToArray(); if (flat.Length == 0 && nested.Length == 0) continue; foreach (var pose in Propose(frame, hole, flat, nested)) diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 0714cc80..204bc3f9 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -304,13 +304,15 @@ closed-cutout frames. It considers legal frame rotations without outer-perimeter reduction, shifts insert rotation policies into the local frame, and reserves certified router poses by original requirement ID. It can fill multiple holes and repeated/different frame requirements; inserts with a different priority stay independent, and a hole smaller in area than -an insert's outline (a bolt hole) is not searched for it. Frames are bundled smallest +an insert's outline (a bolt hole) is not searched for it. A part whose every cutout is smaller in +area than the job's smallest part outline (a strap with bolt holes) is an ordinary insert: no part +can land in its holes. Frames are bundled smallest first, so a larger frame's round cutout can take, through `CutoutCirclePacker` only, a round part -with its own cutouts (a washer with an empty bore) or an earlier bundle as one insert: a flange +with a cutout some part fits (a washer with an empty bore) or an earlier bundle as one insert: a flange holds a washer that holds a smaller washer that holds a slug. Chains stop at four parts deep (`CutoutPipelinePrepass.MaxLevels`). Lattice and NFP proposals never take such inserts, since they could seat a part in a ring's bore or over a nested bundle's reserved inserts; non-round frames -with cutouts stay independent. A frame's proposals are compared by physical parts placed, a nested +with such cutouts stay independent. A frame's proposals are compared by physical parts placed, a nested bundle counting its frame and contents, and ties keep the flat router's proposal. It uses the largest offered spacing for every bundle because the current job contract cannot condition insert demand on the stock selected later. Remaining demand is offered to the selected engine once. Only actually placed proxies expand, recursively, to original frame and