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