diff --git a/OpenNest.Engine.Tests/Fill/TiltedRemnantFillTests.cs b/OpenNest.Engine.Tests/Fill/TiltedRemnantFillTests.cs
new file mode 100644
index 00000000..e85d93bf
--- /dev/null
+++ b/OpenNest.Engine.Tests/Fill/TiltedRemnantFillTests.cs
@@ -0,0 +1,101 @@
+using OpenNest.Engine.Fill;
+using OpenNest.Engine.Jobs;
+using OpenNest.Engine.Jobs.Placement;
+using OpenNest.Engine.Tests.Jobs;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Engine.Tests.Fill;
+
+///
+/// A long part fills a 60 x 120 plate four high at 90 degrees, leaving a 55 x 59 strip too
+/// narrow for it upright. Two more fit tilted, one with a corner tucked beside the grid in the
+/// strip its slack leaves (a reported nest that Fill left at four).
+///
+public class TiltedRemnantFillTests
+{
+ private static Plate LongPartPlate() =>
+ new(new Size(60, 120))
+ {
+ PartSpacing = 0.25,
+ EdgeSpacing = new Spacing(0.25, 0.75, 0.25, 0.25),
+ };
+
+ ///
+ /// The reported part's outline: 11.86 x 63.99 with corner reliefs. The tucked corner needs
+ /// its relief; a plain rectangle of the same size only fits one tilted part.
+ ///
+ private static Drawing LongPart()
+ {
+ var steps = new (double X, double Y)[]
+ {
+ (0, -61.859322), (-0.895, 0), (-0.265621, 0.097161), (0.097161, -0.265622),
+ (0, -0.895), (-9.734323, 0), (0, 0.895), (0.097162, 0.265622),
+ (-0.265622, -0.097161), (-0.895, 0), (0, 61.859322), (0.895, 0),
+ (0.265622, -0.097161), (-0.097162, 0.265621), (0, 0.895), (9.734323, 0),
+ (0, -0.895), (-0.097161, -0.265621), (0.265621, 0.097161), (0.895, 0),
+ };
+
+ // Start at the outline's top-right relief so every point is non-negative.
+ double x = 11.861, y = 62.923;
+ var program = new CNC.Program();
+ program.MoveTo(x, y);
+ foreach (var (dx, dy) in steps)
+ program.LineTo(x += dx, y += dy);
+
+ return new Drawing("long part", program);
+ }
+
+ [Theory]
+ [InlineData(0, 6)]
+ [InlineData(5, 5)]
+ [InlineData(6, 6)]
+ public void FillAddsTiltedPartsBesideTheGrid(int quantity, int expected)
+ {
+ var plate = LongPartPlate();
+ var drawing = LongPart();
+
+ var parts = PlateFillService.FillItem("Fill", plate,
+ new NestItem { Drawing = drawing, Quantity = quantity }, plate.WorkArea(), null,
+ CancellationToken.None);
+
+ Assert.Equal(expected, parts.Count);
+ Assert.Contains(parts, p => !IsSquare(p.Rotation));
+ Assert.Empty(NestLayoutCheck.Validate(new() { (plate, parts) },
+ new Dictionary { [drawing] = ("long part", expected) }));
+ }
+
+ [Fact]
+ public void ExplicitRotationRangeKeepsPartsSquare()
+ {
+ var plate = LongPartPlate();
+ var drawing = LongPart();
+
+ var parts = PlateFillService.FillItem("Fill", plate,
+ new NestItem { Drawing = drawing, RotationStart = 0, RotationEnd = Angle.HalfPI, StepAngle = Angle.HalfPI },
+ plate.WorkArea(), null, CancellationToken.None);
+
+ Assert.Equal(4, parts.Count);
+ Assert.All(parts, p => Assert.True(IsSquare(p.Rotation)));
+ }
+
+ [Fact]
+ public void RemnantThatFitsThePartUprightIsLeftToTheOtherStrategies()
+ {
+ var plate = new Plate(new Size(60, 80)) { PartSpacing = 0.25 };
+ var drawing = new Drawing("part", TestDrawingFactory.Rectangle(10, 20));
+ var workArea = plate.WorkArea();
+ var layout = new List { new(drawing, new Vector(workArea.X, workArea.Y)) };
+
+ var result = new TiltedRemnantFiller(workArea, plate.PartSpacing, new DefaultFillComparer())
+ .Fill(drawing, layout, CancellationToken.None);
+
+ Assert.Null(result);
+ }
+
+ private static bool IsSquare(double rotation)
+ {
+ var quarterTurns = rotation / Angle.HalfPI;
+ return System.Math.Abs(quarterTurns - System.Math.Round(quarterTurns)) < 1e-6;
+ }
+}
diff --git a/OpenNest.Engine/Fill/TiltedRemnantFiller.cs b/OpenNest.Engine/Fill/TiltedRemnantFiller.cs
new file mode 100644
index 00000000..36c6f595
--- /dev/null
+++ b/OpenNest.Engine/Fill/TiltedRemnantFiller.cs
@@ -0,0 +1,378 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Converters;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Engine.Fill
+{
+ ///
+ /// Adds tilted parts beside a layout whose leftover strip is too narrow for the part at 0 or
+ /// 90 degrees, such as a long part beside a grid of the same part turned 90 degrees.
+ /// The layout also moves flush to one side so its slack forms one strip a tilted part's corner
+ /// can tuck into. Added parts clear the layout by the part spacing, tested on the validation
+ /// outlines Auto Nest's layout check uses.
+ ///
+ public class TiltedRemnantFiller
+ {
+ private static readonly double SampleStep = Angle.ToRadians(0.25);
+
+ // Steps (in samples) from the most upright fitting angle into its feasible range: the
+ // hull's extents are approximate, and a slightly steeper tilt can tile better.
+ private static readonly int[] SampleOffsets = { 0, 2, 4, 8, 12 };
+
+ private readonly Box workArea;
+ private readonly double partSpacing;
+ private readonly IFillComparer comparer;
+ private readonly Dictionary<(CNC.Program, double), SpacingOutline> localOutlines = new();
+
+ // Placed copies are tested against every layout variant; reference keys are per copy.
+ private readonly Dictionary inflatedOutlines = new(ReferenceEqualityComparer.Instance);
+
+ public TiltedRemnantFiller(Box workArea, double partSpacing, IFillComparer comparer)
+ {
+ this.workArea = workArea;
+ this.partSpacing = partSpacing;
+ this.comparer = comparer;
+ }
+
+ ///
+ /// Returns (possibly moved) plus tilted parts when that beats
+ /// the layout, or null when no tilted placement helps.
+ ///
+ public List Fill(Drawing drawing, List layout, CancellationToken token)
+ {
+ if (layout == null || layout.Count == 0)
+ return null;
+
+ var hull = MaterialHull(drawing);
+ if (hull == null)
+ return null;
+
+ List best = null;
+
+ foreach (var alongX in new[] { true, false })
+ {
+ token.ThrowIfCancellationRequested();
+ var candidate = FillSide(drawing, hull, layout, alongX, token);
+ if (candidate != null && comparer.IsBetter(candidate, best ?? layout, workArea))
+ best = candidate;
+ }
+
+ return best;
+ }
+
+ ///
+ /// Fills past the layout's high edge on one axis: the right side when
+ /// , otherwise the top.
+ ///
+ private List FillSide(
+ Drawing drawing,
+ List hull,
+ List layout,
+ bool alongX,
+ CancellationToken token
+ )
+ {
+ // Move the layout flush to the low end so leftover room on both sides becomes one remnant.
+ var layoutBox = ((IEnumerable)layout).GetBoundingBox();
+ var toLow = Low(workArea, alongX) - Low(layoutBox, alongX);
+ if (!toLow.IsEqualTo(0))
+ {
+ layout = Offset(layout, Point(alongX, toLow, 0));
+ layoutBox = ((IEnumerable)layout).GetBoundingBox();
+ }
+
+ var remnantLow = High(layoutBox, alongX) + partSpacing;
+ var remnantSpan = High(workArea, alongX) - remnantLow;
+ var crossSpan = Span(workArea, !alongX);
+
+ if (remnantSpan <= Tolerance.Epsilon)
+ return null;
+
+ // When the part fits upright, the 0/90-degree strategies already filled this strip.
+ foreach (var angle in new[] { 0.0, Angle.HalfPI })
+ {
+ var (along, cross) = Extents(hull, angle, alongX);
+ if (along <= remnantSpan + Tolerance.Epsilon && cross <= crossSpan + Tolerance.Epsilon)
+ return null;
+ }
+
+ // A tilted part may reach back over the layout only beside it, in the strip its slack
+ // leaves; without slack it must stay in the remnant itself.
+ var slack = crossSpan - Span(layoutBox, !alongX);
+ var reach = slack > partSpacing ? Span(layoutBox, alongX) : 0;
+
+ var angles = CandidateAngles(hull, alongX, crossSpan, remnantSpan + reach);
+ if (angles.Count == 0)
+ return null;
+
+ var variants = LayoutVariants(layout, layoutBox, alongX, slack);
+ var region = Rect(
+ alongX,
+ System.Math.Max(Low(workArea, alongX), remnantLow - reach),
+ High(workArea, alongX),
+ Low(workArea, !alongX),
+ High(workArea, !alongX)
+ );
+
+ List best = null;
+
+ foreach (var angle in angles)
+ {
+ foreach (var direction in new[] { NestDirection.Horizontal, NestDirection.Vertical })
+ {
+ token.ThrowIfCancellationRequested();
+
+ var lattice = new FillLinear(region, partSpacing) { Label = "TiltedRemnant" }
+ .Fill(drawing, angle, direction);
+ if (lattice == null || lattice.Count == 0)
+ continue;
+
+ foreach (var placed in Alignments(lattice, alongX))
+ {
+ foreach (var variant in variants)
+ {
+ var kept = placed
+ .Where(p => Low(p.BoundingBox, alongX) >= remnantLow - Tolerance.Epsilon
+ || variant.Clears(p))
+ .ToList();
+ if (kept.Count == 0)
+ continue;
+
+ var candidate = new List(variant.Parts.Count + kept.Count);
+ candidate.AddRange(variant.Parts);
+ candidate.AddRange(kept);
+
+ if (comparer.IsBetter(candidate, best, workArea))
+ best = candidate;
+ }
+ }
+ }
+ }
+
+ return best;
+ }
+
+ ///
+ /// The lattice pushed against the far wall, at both cross-axis ends: the far wall leaves
+ /// the most room beside the layout, and either cross end may put a corner in its strip.
+ ///
+ private IEnumerable> Alignments(List lattice, bool alongX)
+ {
+ var box = ((IEnumerable)lattice).GetBoundingBox();
+ var along = High(workArea, alongX) - High(box, alongX);
+ var toLow = Low(workArea, !alongX) - Low(box, !alongX);
+ var toHigh = High(workArea, !alongX) - High(box, !alongX);
+
+ yield return Offset(lattice, Point(alongX, along, toLow));
+
+ if (!toHigh.IsEqualTo(toLow))
+ yield return Offset(lattice, Point(alongX, along, toHigh));
+ }
+
+ private static List Offset(List parts, Vector offset) =>
+ parts.Select(p => p.CloneAtOffset(offset)).ToList();
+
+ ///
+ /// The layout as placed and moved flush to either cross-axis end, so its slack collects
+ /// in one strip on the other side.
+ ///
+ private List LayoutVariants(
+ List layout,
+ Box layoutBox,
+ bool alongX,
+ double slack
+ )
+ {
+ var variants = new List { new(layout, this) };
+ if (slack <= partSpacing)
+ return variants;
+
+ var shifts = new[]
+ {
+ Low(workArea, !alongX) - Low(layoutBox, !alongX),
+ High(workArea, !alongX) - High(layoutBox, !alongX),
+ };
+
+ foreach (var shift in shifts.Where(s => !s.IsEqualTo(0)).Distinct())
+ variants.Add(new LayoutVariant(Offset(layout, Point(alongX, 0, shift)), this));
+
+ return variants;
+ }
+
+ ///
+ /// For each range of angles at which the part spans no more than
+ /// across and along, the most
+ /// upright angle (the narrowest along) and a few steeper ones.
+ ///
+ private static List CandidateAngles(
+ List hull,
+ bool alongX,
+ double crossLimit,
+ double alongLimit
+ )
+ {
+ var count = (int)System.Math.Round(System.Math.PI / SampleStep);
+ var along = new double[count];
+ var fits = new bool[count];
+
+ for (var i = 0; i < count; i++)
+ {
+ var (a, c) = Extents(hull, i * SampleStep, alongX);
+ along[i] = a;
+ fits[i] = a <= alongLimit + Tolerance.Epsilon && c <= crossLimit + Tolerance.Epsilon;
+ }
+
+ var angles = new List();
+ if (fits.All(f => f))
+ return angles;
+
+ // Extents repeat every 180 degrees, so a range may wrap; start scanning after a gap.
+ var start = System.Array.IndexOf(fits, false);
+ for (var k = 1; k <= count; k++)
+ {
+ var first = (start + k) % count;
+ if (!fits[first] || fits[(first - 1 + count) % count])
+ continue;
+
+ var range = new List();
+ for (var i = first; fits[i]; i = (i + 1) % count)
+ range.Add(i);
+
+ var upright = range.IndexOf(range.OrderBy(i => along[i]).First());
+ foreach (var offset in SampleOffsets)
+ {
+ foreach (var at in new[] { upright - offset, upright + offset })
+ {
+ if (at < 0 || at >= range.Count)
+ continue;
+ var angle = range[at] * SampleStep;
+ if (!angles.Any(a => a.IsEqualTo(angle)))
+ angles.Add(angle);
+ }
+ }
+ }
+
+ return angles;
+ }
+
+ private static (double Along, double Cross) Extents(List hull, double angle, bool alongX)
+ {
+ var cos = System.Math.Cos(angle);
+ var sin = System.Math.Sin(angle);
+ double minX = double.MaxValue, maxX = double.MinValue;
+ double minY = double.MaxValue, maxY = double.MinValue;
+
+ foreach (var v in hull)
+ {
+ var x = v.X * cos - v.Y * sin;
+ var y = v.X * sin + v.Y * cos;
+ minX = System.Math.Min(minX, x);
+ maxX = System.Math.Max(maxX, x);
+ minY = System.Math.Min(minY, y);
+ maxY = System.Math.Max(maxY, y);
+ }
+
+ return alongX ? (maxX - minX, maxY - minY) : (maxY - minY, maxX - minX);
+ }
+
+ private static List MaterialHull(Drawing drawing)
+ {
+ var shapes = ShapeBuilder.GetShapes(ConvertProgram.ToGeometry(drawing.Program).ForNesting());
+ var points = shapes
+ .SelectMany(s => s.ToPolygonWithTolerance(0.01)?.Vertices ?? new List())
+ .ToList();
+
+ return points.Count < 3 ? null : ConvexHull.Compute(points).Vertices;
+ }
+
+ ///
+ /// The part's perimeter outline grown by . Cutouts are left
+ /// out: tilted parts go beside the layout, never into it, so treating parts as solid only
+ /// rejects placements this filler never looks for. Built once per program and moved.
+ ///
+ private SpacingOutline Outline(Part part, double inflateBy)
+ {
+ var key = (part.Program, inflateBy);
+ if (!localOutlines.TryGetValue(key, out var local))
+ {
+ var profile = new ShapeProfile(ConvertProgram.ToGeometry(part.Program).ForNesting());
+ local = profile.Perimeter == null
+ ? null
+ : SpacingOutline.Build(new ShapeProfile(profile.Perimeter), new Vector(0, 0), inflateBy);
+ localOutlines[key] = local;
+ }
+
+ if (local == null)
+ return null;
+
+ var perimeter = (Polygon)local.Perimeter.Clone();
+ perimeter.Offset(part.Location);
+ perimeter.UpdateBounds();
+ return new SpacingOutline(perimeter, new List());
+ }
+
+ private SpacingOutline InflatedOutline(Part part)
+ {
+ if (!inflatedOutlines.TryGetValue(part, out var outline))
+ {
+ outline = Outline(part, SpacingOutline.InflationFor(partSpacing));
+ inflatedOutlines[part] = outline;
+ }
+
+ return outline;
+ }
+
+ private static double Low(Box box, bool alongX) => alongX ? box.Left : box.Bottom;
+
+ private static double High(Box box, bool alongX) => alongX ? box.Right : box.Top;
+
+ private static double Span(Box box, bool alongX) => alongX ? box.Length : box.Width;
+
+ private static Vector Point(bool alongX, double along, double cross) =>
+ alongX ? new Vector(along, cross) : new Vector(cross, along);
+
+ private static Box Rect(bool alongX, double alongLow, double alongHigh, double crossLow, double crossHigh) =>
+ alongX
+ ? new Box(alongLow, crossLow, alongHigh - alongLow, crossHigh - crossLow)
+ : new Box(crossLow, alongLow, crossHigh - crossLow, alongHigh - alongLow);
+
+ /// One position of the layout, with its raw outlines built on first need.
+ private sealed class LayoutVariant
+ {
+ private readonly TiltedRemnantFiller owner;
+ private readonly SpacingOutline[] outlines;
+
+ public LayoutVariant(List parts, TiltedRemnantFiller owner)
+ {
+ Parts = parts;
+ this.owner = owner;
+ outlines = new SpacingOutline[parts.Count];
+ }
+
+ public List Parts { get; }
+
+ /// True when is at least the part spacing from every layout part.
+ public bool Clears(Part part)
+ {
+ var reach = part.BoundingBox.Offset(SpacingOutline.InflationFor(owner.partSpacing));
+ SpacingOutline inflated = null;
+
+ for (var i = 0; i < Parts.Count; i++)
+ {
+ if (!reach.Intersects(Parts[i].BoundingBox))
+ continue;
+
+ inflated ??= owner.InflatedOutline(part);
+ outlines[i] ??= owner.Outline(Parts[i], 0);
+ if (inflated == null || outlines[i] == null || inflated.Overlaps(outlines[i]))
+ return false;
+ }
+
+ return true;
+ }
+ }
+ }
+}
diff --git a/OpenNest.Engine/Strategies/TiltedRemnantStrategy.cs b/OpenNest.Engine/Strategies/TiltedRemnantStrategy.cs
new file mode 100644
index 00000000..6764d4e7
--- /dev/null
+++ b/OpenNest.Engine/Strategies/TiltedRemnantStrategy.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using OpenNest.Engine.Fill;
+using OpenNest.Math;
+
+namespace OpenNest.Engine.Strategies;
+
+///
+/// Runs after the other strategies and adds tilted parts beside their best layout, where the
+/// leftover strip is too narrow for the part at 0 or 90 degrees. See .
+///
+public class TiltedRemnantStrategy : IFillStrategy
+{
+ public string Name => "TiltedRemnant";
+ public NestPhase Phase => NestPhase.Custom;
+ public int Order => 500;
+
+ public List Fill(FillContext context)
+ {
+ var layout = context.CurrentBest;
+ if (layout == null || layout.Count == 0 || context.PartType == PartType.Circle)
+ return null;
+
+ // Tilting would break an explicit rotation range.
+ var item = context.Item;
+ if (!item.RotationStart.IsEqualTo(0) || !item.RotationEnd.IsEqualTo(0))
+ return null;
+
+ var target = context.MaxQuantity > 0 ? context.MaxQuantity : item.Quantity;
+ if (target > 0 && layout.Count >= target)
+ return null;
+
+ var filler = new TiltedRemnantFiller(
+ context.WorkArea,
+ context.Plate.PartSpacing,
+ context.Policy.Comparer
+ );
+ var result = filler.Fill(item.Drawing, layout, context.Token);
+
+ if (result != null)
+ context.ReportProgress(result, $"Tilted remnant: {result.Count} parts");
+
+ return result;
+ }
+}
diff --git a/OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs b/OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs
index d152e065..b2de875e 100644
--- a/OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs
+++ b/OpenNest.Tests/Strategies/FillStrategyRegistryTests.cs
@@ -35,12 +35,13 @@ public class FillStrategyRegistryTests
}
[Fact]
- public void Registry_LinearIsLast()
+ public void Registry_LinearIsLastBeforeTiltedRemnant()
{
+ // TiltedRemnant adds to the best layout, so it follows every strategy that builds one.
var strategies = FillStrategyRegistry.Strategies;
- var last = strategies[strategies.Count - 1];
- Assert.Equal("Linear", last.Name);
+ Assert.Equal("TiltedRemnant", strategies[strategies.Count - 1].Name);
+ Assert.Equal("Linear", strategies[strategies.Count - 2].Name);
}
[Fact]
diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md
index 012c402f..f7c92bd6 100644
--- a/docs/nesting-engines.md
+++ b/docs/nesting-engines.md
@@ -58,6 +58,15 @@ detail reads "Computing best-fit pairs (may take a minute)...". Accepting or sto
waiting for the pair computation, which finishes in the background and is cached for the next
fill.
+Fill's last strategy, TiltedRemnant, adds tilted parts beside the best layout when the room
+left past it on the right or top is too narrow for the part at 0 or 90 degrees, such as a long
+part beside a grid of the same part turned 90 degrees. It moves the layout flush to the low end
+of that axis, so leftover room on both sides becomes one remnant, and also to either end of the
+other axis, so the layout's slack forms one strip a tilted part's corner can tuck into. It tiles
+the part at the most upright angles that fit the remnant's height and keeps only copies at least
+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.
+
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