diff --git a/OpenNest.Engine.Tests/Fill/EndRowRefillTests.cs b/OpenNest.Engine.Tests/Fill/EndRowRefillTests.cs new file mode 100644 index 00000000..8e4e025d --- /dev/null +++ b/OpenNest.Engine.Tests/Fill/EndRowRefillTests.cs @@ -0,0 +1,106 @@ +using System.Diagnostics; +using OpenNest.CNC; +using OpenNest.Engine.Fill; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Geometry; +using Xunit.Abstractions; + +namespace OpenNest.Engine.Tests.Fill; + +/// +/// A reported nest: a 7" hexagon cover with a 2.5" center hole and six bolt holes on a +/// 60 x 120 plate. Fill tiled 10 staggered rows of 18 (180) and left 2.7" above the top row; +/// lifting that row straight over the one below opens a notch at its end where one more +/// hexagon nests (181), as the user laid it out by hand. +/// +public class EndRowRefillTests +{ + private readonly ITestOutputHelper output; + + public EndRowRefillTests(ITestOutputHelper output) => this.output = output; + + private static Plate HexPlate() => + new(new Size(60, 120)) + { + PartSpacing = 0.25, + EdgeSpacing = new Spacing(0.25, 0.25, 0.25, 0.25), + }; + + /// The reported drawing's program, in absolute coordinates. + private static Drawing HexCover() + { + var program = new Program(); + foreach (var (x, y, r) in new[] + { + (0.0, 0.0, 0.1875), (-1.9918584287, 1.15, 0.1875), (-3.9837168574, 0.0, 0.1875), + (-3.9837168574, -2.3, 0.1875), (-1.9918584287, -3.45, 0.1875), (0.0, -2.3, 0.1875), + (-0.9293584287, -1.15, 1.25), + }) + { + program.MoveTo(x, y); + program.ArcTo(new Vector(x, y), new Vector(x - r, y), RotationType.CCW); + } + + program.MoveTo(1.3206415713, -1.15); + foreach (var (x, y) in new[] + { + (-0.4293584287, -4.1810889132), (-3.9293584287, -4.1810889132), (-5.6793584287, -1.15), + (-3.9293584287, 1.8810889132), (-0.4293584287, 1.8810889132), (1.3206415713, -1.15), + }) + program.LineTo(x, y); + + return new Drawing("Hex Cover", program); + } + + [Fact] + public void FillNestsOneMoreHexagonAtTheEndOfTheLiftedTopRow() + { + var plate = HexPlate(); + var drawing = HexCover(); + + var parts = PlateFillService.FillItem("Fill", plate, + new NestItem { Drawing = drawing, Quantity = 0 }, plate.WorkArea(), null, + CancellationToken.None); + + Assert.Equal(181, parts.Count); + Assert.Empty(NestLayoutCheck.Validate(new() { (plate, parts) }, + new Dictionary { [drawing] = ("Hex Cover", 181) })); + } + + [Fact] + public void ImproveKeepsAGridThatHasNoRoomAtItsEnds() + { + var plate = new Plate(new Size(40, 40)) { PartSpacing = 0.5 }; + var drawing = new Drawing("block", Jobs.TestDrawingFactory.Rectangle(9.5, 9.5)); + var parts = new List(); + for (var x = 0; x < 4; x++) + for (var y = 0; y < 4; y++) + parts.Add(new Part(drawing, new Vector(x * 10, y * 10))); + + var result = EndRowRefiller.Improve(parts, plate.WorkArea(), plate.PartSpacing, + new DefaultFillComparer(), 0, CancellationToken.None); + + Assert.Same(parts, result); + } + + [Fact] + public void ImproveStopsAtTheRequestedQuantity() + { + var plate = HexPlate(); + var drawing = HexCover(); + var full = PlateFillService.FillItem("Fill", plate, + new NestItem { Drawing = drawing, Quantity = 0 }, plate.WorkArea(), null, + CancellationToken.None); + + var capped = EndRowRefiller.Improve(full, plate.WorkArea(), plate.PartSpacing, + new DefaultFillComparer(), full.Count, CancellationToken.None); + Assert.Same(full, capped); + + // Reports what the pass costs on a full plate; not asserted. + var stopwatch = Stopwatch.StartNew(); + EndRowRefiller.Improve(full, plate.WorkArea(), plate.PartSpacing, + new DefaultFillComparer(), 0, CancellationToken.None); + output.WriteLine($"Improve on {full.Count} parts: {stopwatch.ElapsedMilliseconds} ms"); + } +} diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs index 20dfdbcb..6fe4f8ef 100644 --- a/OpenNest.Engine/Fill/Compactor.cs +++ b/OpenNest.Engine/Fill/Compactor.cs @@ -85,7 +85,7 @@ namespace OpenNest.Engine.Fill /// Pushes movingParts to the first contact with the work-area edge or an obstacle, /// keeping each obstacle set's own spacing. /// - private static double Push( + internal static double Push( List movingParts, Box workArea, Vector direction, @@ -122,7 +122,7 @@ namespace OpenNest.Engine.Fill /// Obstacles kept at one spacing, with their bounds and spacing-offset contours /// cached for one push. /// - private sealed class ObstacleSet + internal sealed class ObstacleSet { private readonly List parts; private readonly Box[] spacingBoxes; diff --git a/OpenNest.Engine/Fill/EndRowRefiller.cs b/OpenNest.Engine/Fill/EndRowRefiller.cs new file mode 100644 index 00000000..2660683d --- /dev/null +++ b/OpenNest.Engine/Fill/EndRowRefiller.cs @@ -0,0 +1,272 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Fill +{ + /// + /// Tries to fit more parts at the end of a fill's outermost row. A lattice usually leaves + /// slack between its last row and the work-area edge and a gap at the end of each row that + /// is too short for one more copy. This pass pushes the outer row into that slack, slides + /// it toward one end of the row, then slides copies of the row's end part in from the freed + /// end. Lifting staggered hexagons straight over the row below, for instance, opens a notch + /// beside the last pair where one more hexagon nests. + /// + /// + /// Every move is a push, so the parts keep the part spacing from + /// each other and stay in the work area by construction; an inserted copy is kept only when + /// it stops inside the work area. The caller's comparer decides whether the result wins. + /// + public static class EndRowRefiller + { + /// A row is the parts whose outer bounds lie this close to the outermost. + private const double RowTolerance = 1e-3; + + /// Most positions across the row a copy is slid in from. + private const int MaxSamples = 96; + + private static readonly (PushDirection Outward, PushDirection[] Along)[] Sides = + { + (PushDirection.Up, new[] { PushDirection.Left, PushDirection.Right }), + (PushDirection.Right, new[] { PushDirection.Down, PushDirection.Up }), + (PushDirection.Down, new[] { PushDirection.Left, PushDirection.Right }), + (PushDirection.Left, new[] { PushDirection.Down, PushDirection.Up }), + }; + + /// + /// The best layout finds among and + /// its end-row refills, or itself when none beats it. + /// caps the part count when positive. + /// + public static List Improve( + List parts, + Box workArea, + double partSpacing, + IFillComparer comparer, + int maxQuantity, + CancellationToken token + ) + { + if (parts == null || parts.Count < 2) + return parts; + if (maxQuantity > 0 && parts.Count >= maxQuantity) + return parts; + + var best = parts; + foreach (var (outward, along) in Sides) + { + foreach (var slide in along) + { + token.ThrowIfCancellationRequested(); + var candidate = TrySide(parts, workArea, partSpacing, outward, slide, maxQuantity); + if (candidate != null && comparer.IsBetter(candidate, best, workArea)) + best = candidate; + } + } + + return best; + } + + private static List TrySide( + List parts, + Box workArea, + double spacing, + PushDirection outward, + PushDirection slide, + int maxQuantity + ) + { + var outerEdge = parts.Max(p => OuterEdge(p.BoundingBox, outward)); + var row = new List(); + var rest = new List(); + foreach (var part in parts) + { + if (OuterEdge(part.BoundingBox, outward) >= outerEdge - RowTolerance) + row.Add(part.CloneAtOffset(Vector.Zero)); + else + rest.Add(part); + } + + if (row.Count < 2) + return null; + + // Into the edge slack first, so the row can pass over the one below it, then + // along the row. The outward push is what frees the slide. + var lifted = Compactor.Push(row, rest, workArea, spacing, outward); + var slid = Compactor.Push(row, rest, workArea, spacing, slide); + if (lifted <= 0 && slid <= 0) + return null; + + var obstacles = new List(rest.Count + row.Count); + obstacles.AddRange(rest); + obstacles.AddRange(row); + + var templates = row + .GroupBy(p => (p.BaseDrawing, System.Math.Round(p.Rotation, 9))) + .Select(g => g.First()) + .ToList(); + + var inserted = new List(); + var limit = maxQuantity > 0 ? maxQuantity - parts.Count : row.Count; + while (inserted.Count < limit) + { + var next = InsertOne(templates, obstacles, row, workArea, spacing, outward, slide); + if (next == null) + break; + inserted.Add(next); + obstacles.Add(next); + } + + if (inserted.Count == 0) + return null; + + var result = new List(rest.Count + row.Count + inserted.Count); + result.AddRange(rest); + result.AddRange(row); + result.AddRange(inserted); + return result; + } + + /// + /// A copy slid in along from beyond the work area at the + /// position across the row band that lets it travel deepest, or null when no position + /// leaves it inside the work area. + /// + private static Part InsertOne( + List templates, + List obstacles, + List row, + Box workArea, + double spacing, + PushDirection outward, + PushDirection slide + ) + { + var vertical = outward == PushDirection.Up || outward == PushDirection.Down; + var rowBox = row.GetBoundingBox(); + var direction = SpatialQuery.DirectionToOffset(slide, 1.0); + + // One set for the whole sweep, so each obstacle's offset contour is built once. + var obstacleSet = new Compactor.ObstacleSet(obstacles, spacing); + var moving = new List(1); + + Part best = null; + var bestDepth = double.MaxValue; + + foreach (var template in templates) + { + var box = template.BoundingBox; + var size = vertical ? box.Width : box.Length; + + // Across the row: from one part size inside the row's inner edge to the + // work-area edge, so a copy can drop into notches between the row and the + // one below it. + double lo, hi; + if (vertical) + { + lo = System.Math.Max(workArea.Bottom, rowBox.Bottom - size); + hi = System.Math.Min(workArea.Top - size, rowBox.Top); + } + else + { + lo = System.Math.Max(workArea.Left, rowBox.Left - size); + hi = System.Math.Min(workArea.Right - size, rowBox.Right); + } + + if (hi < lo) + continue; + + var step = System.Math.Max(spacing / 2, Tolerance.Epsilon); + var samples = (int)System.Math.Ceiling((hi - lo) / step); + samples = System.Math.Clamp(samples, 1, MaxSamples); + + for (var i = 0; i <= samples; i++) + { + var across = lo + (hi - lo) * i / samples; + var copy = StartBeyond(template, workArea, spacing, slide, across, vertical); + moving.Clear(); + moving.Add(copy); + var travel = Compactor.Push(moving, workArea, direction, obstacleSet); + if (travel <= 0) + continue; + + var landed = copy.BoundingBox; + if (!Inside(landed, workArea)) + continue; + + var depth = LeadingEdge(landed, slide); + if (depth < bestDepth - Tolerance.Epsilon) + { + bestDepth = depth; + best = copy; + } + } + } + + return best; + } + + /// + /// A copy of just beyond the work-area edge the slide + /// starts from, its inner bound at across the row. + /// + private static Part StartBeyond( + Part template, + Box workArea, + double spacing, + PushDirection slide, + double across, + bool vertical + ) + { + var box = template.BoundingBox; + var gap = spacing + 1.0; + double dx, dy; + if (vertical) + { + dy = across - box.Bottom; + dx = slide == PushDirection.Left + ? workArea.Right + gap - box.Left + : workArea.Left - gap - box.Right; + } + else + { + dx = across - box.Left; + dy = slide == PushDirection.Down + ? workArea.Top + gap - box.Bottom + : workArea.Bottom - gap - box.Top; + } + + var copy = template.CloneAtOffset(new Vector(dx, dy)); + copy.UpdateBounds(); + return copy; + } + + /// How far the copy got: smaller is deeper along the slide. + private static double LeadingEdge(Box box, PushDirection slide) => + slide switch + { + PushDirection.Left => box.Left, + PushDirection.Right => -box.Right, + PushDirection.Down => box.Bottom, + _ => -box.Top, + }; + + private static double OuterEdge(Box box, PushDirection outward) => + outward switch + { + PushDirection.Up => box.Top, + PushDirection.Right => box.Right, + PushDirection.Down => -box.Bottom, + _ => -box.Left, + }; + + private static bool Inside(Box box, Box workArea) => + box.Left >= workArea.Left - Tolerance.Epsilon + && box.Right <= workArea.Right + Tolerance.Epsilon + && box.Bottom >= workArea.Bottom - Tolerance.Epsilon + && box.Top <= workArea.Top + Tolerance.Epsilon; + } +} diff --git a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFiller.cs b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFiller.cs index 953c17d7..060292d7 100644 --- a/OpenNest.Engine/Jobs/Placement/Fillers/PlateFiller.cs +++ b/OpenNest.Engine/Jobs/Placement/Fillers/PlateFiller.cs @@ -657,6 +657,8 @@ internal class PlateFiller ); } } + + RefineEndRow(context); } catch (OperationCanceledException) { @@ -672,6 +674,49 @@ internal class PlateFiller angleBuilder.RecordProductive(context.AngleResults); } + /// + /// Gives the winning layout's outermost row a chance to make room for more parts at its end + /// (); the policy's comparer must prefer the result. + /// + private void RefineEndRow(FillContext context) + { + var current = context.CurrentBest; + if (current == null || current.Count < 2) + return; + + var stopwatch = Stopwatch.StartNew(); + var refined = EndRowRefiller.Improve( + current, + context.WorkArea, + Plate.PartSpacing, + context.Policy.Comparer, + context.MaxQuantity, + context.Token + ); + stopwatch.Stop(); + Debug.WriteLine( + $"[RunPipeline] End-row refill: {current.Count} -> {refined.Count} in {stopwatch.ElapsedMilliseconds}ms" + ); + + if (ReferenceEquals(refined, current)) + return; + + context.CurrentBest = refined; + context.CurrentBestScore = FillScore.Compute(refined, context.WorkArea); + NestProgressReporter.Report( + context.Progress, + new ProgressReport + { + Phase = context.WinnerPhase, + PlateNumber = PlateNumber, + Parts = context.ToOriginalFrame(refined), + WorkArea = context.WorkArea, + Description = BuildProgressSummary(), + IsOverallBest = true, + } + ); + } + /// /// Shows the box-packing layout before best-fit pairs, whose first computation for a drawing /// can take a long time, so the user can accept a usable layout at once. Only offered when the diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index be1fe8a3..fde5fad9 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -124,6 +124,17 @@ the part at the most upright angles that fit the remnant's height and keeps only the part spacing from the layout, tested on the outlines `NestLayoutCheck` uses. It is skipped for circles, for an explicit rotation range, and once the layout holds the requested quantity. +After the strategies, the end-row refill (`EndRowRefiller`) works on the winning layout's +outermost row on each side. It pushes the row out into the slack before the work-area edge, +then slides it toward one end of the row. Copies of the row's end part then slide in from the +freed end, each at whichever position across the row lets it travel deepest. Every move is a +`Compactor` push, so parts keep the part spacing, and a copy counts only when it stops inside +the work area. The layout is kept only when the policy's comparer prefers it, and the refill +never goes past the requested quantity. A 7-inch hexagon cover on a 60 x 120 sheet at 0.25 +spacing fills 180 in ten staggered rows of 18 with 2.7 inches above the top row. Lifting that +row straight over the one below opens a notch at its end where one more hexagon nests, so the +plate holds 181. + On a plate with clamps, interactive Fill (Fill Area and filling from a part) fills as if the clamps were absent, then drops every part that enters a clamp keep-out or sits too close to an existing part, and a whole copy of a multi-part group when any member does. Packing around