From 5adcddc748fa55d486c13ade08e6a364cbc6ec6b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 8 Nov 2026 21:46:03 -0500 Subject: [PATCH] feat(fill): refill the outer row's end to fit one more part After the fill strategies, the winning layout's outermost row on each side is pushed into the edge slack, slid toward one end, and copies of its end part are slid in from the freed end. Lifting a staggered hexagon row straight over the one below opens a notch where one more nests: a 7" hex cover on a 60 x 120 sheet now fills 181 instead of 180. The policy's comparer must prefer the result and the requested quantity caps it. Co-Authored-By: Claude Opus 5.5 --- .../Fill/EndRowRefillTests.cs | 106 +++++++ OpenNest.Engine/Fill/Compactor.cs | 4 +- OpenNest.Engine/Fill/EndRowRefiller.cs | 272 ++++++++++++++++++ .../Jobs/Placement/Fillers/PlateFiller.cs | 45 +++ docs/nesting-engines.md | 11 + 5 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 OpenNest.Engine.Tests/Fill/EndRowRefillTests.cs create mode 100644 OpenNest.Engine/Fill/EndRowRefiller.cs 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