feat(fill): add tilted parts beside a layout whose leftover strip is too narrow upright

Fill only tried 0 and 90 degrees for parts classified as rectangles, so a long
part filling a 60 x 120 plate four high at 90 degrees left a 55 x 59 strip empty
although two more fit tilted about 33 degrees (a reported nest: 4 placed, 6 fit).
The second tilted part only fits with its corner tucked beside the grid, in the
strip the grid's slack leaves, so a plain rectangular remnant fill finds one.

TiltedRemnant runs after the other strategies. It moves the best layout flush to
one end so leftover room becomes one remnant, and to either end of the other axis
so its slack forms one strip; then 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. It is skipped when the part fits the remnant upright, for circles, for an
explicit rotation range and once the requested quantity is placed.

Fill benchmark (--parallel 1): the synthetic set is unchanged (all valid, same
cost and plates). The reported part needs 1 sheet instead of 2 for 6 parts and
2 instead of 3 for 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-21 13:49:59 -04:00
1 parent 5ecdc98581
commit 35359906fe
5 files changed
+536 -3

No files matched your search

@@ -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;
/// <summary>
/// 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).
/// </summary>
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),
};
/// <summary>
/// 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.
/// </summary>
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, (string, int)> { [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<Part> { 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;
}
}
+378
View File
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<Part, SpacingOutline> inflatedOutlines = new(ReferenceEqualityComparer.Instance);
public TiltedRemnantFiller(Box workArea, double partSpacing, IFillComparer comparer)
{
this.workArea = workArea;
this.partSpacing = partSpacing;
this.comparer = comparer;
}
/// <summary>
/// Returns <paramref name="layout"/> (possibly moved) plus tilted parts when that beats
/// the layout, or null when no tilted placement helps.
/// </summary>
public List<Part> Fill(Drawing drawing, List<Part> layout, CancellationToken token)
{
if (layout == null || layout.Count == 0)
return null;
var hull = MaterialHull(drawing);
if (hull == null)
return null;
List<Part> 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;
}
/// <summary>
/// Fills past the layout's high edge on one axis: the right side when
/// <paramref name="alongX"/>, otherwise the top.
/// </summary>
private List<Part> FillSide(
Drawing drawing,
List<Vector> hull,
List<Part> 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<IBoundable>)layout).GetBoundingBox();
var toLow = Low(workArea, alongX) - Low(layoutBox, alongX);
if (!toLow.IsEqualTo(0))
{
layout = Offset(layout, Point(alongX, toLow, 0));
layoutBox = ((IEnumerable<IBoundable>)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<Part> 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<Part>(variant.Parts.Count + kept.Count);
candidate.AddRange(variant.Parts);
candidate.AddRange(kept);
if (comparer.IsBetter(candidate, best, workArea))
best = candidate;
}
}
}
}
return best;
}
/// <summary>
/// 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.
/// </summary>
private IEnumerable<List<Part>> Alignments(List<Part> lattice, bool alongX)
{
var box = ((IEnumerable<IBoundable>)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<Part> Offset(List<Part> parts, Vector offset) =>
parts.Select(p => p.CloneAtOffset(offset)).ToList();
/// <summary>
/// The layout as placed and moved flush to either cross-axis end, so its slack collects
/// in one strip on the other side.
/// </summary>
private List<LayoutVariant> LayoutVariants(
List<Part> layout,
Box layoutBox,
bool alongX,
double slack
)
{
var variants = new List<LayoutVariant> { 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;
}
/// <summary>
/// For each range of angles at which the part spans no more than
/// <paramref name="crossLimit"/> across and <paramref name="alongLimit"/> along, the most
/// upright angle (the narrowest along) and a few steeper ones.
/// </summary>
private static List<double> CandidateAngles(
List<Vector> 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<double>();
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<int>();
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<Vector> 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<Vector> MaterialHull(Drawing drawing)
{
var shapes = ShapeBuilder.GetShapes(ConvertProgram.ToGeometry(drawing.Program).ForNesting());
var points = shapes
.SelectMany(s => s.ToPolygonWithTolerance(0.01)?.Vertices ?? new List<Vector>())
.ToList();
return points.Count < 3 ? null : ConvexHull.Compute(points).Vertices;
}
/// <summary>
/// The part's perimeter outline grown by <paramref name="inflateBy"/>. 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.
/// </summary>
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<Polygon>());
}
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);
/// <summary>One position of the layout, with its raw outlines built on first need.</summary>
private sealed class LayoutVariant
{
private readonly TiltedRemnantFiller owner;
private readonly SpacingOutline[] outlines;
public LayoutVariant(List<Part> parts, TiltedRemnantFiller owner)
{
Parts = parts;
this.owner = owner;
outlines = new SpacingOutline[parts.Count];
}
public List<Part> Parts { get; }
/// <summary>True when <paramref name="part"/> is at least the part spacing from every layout part.</summary>
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;
}
}
}
}
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using OpenNest.Engine.Fill;
using OpenNest.Math;
namespace OpenNest.Engine.Strategies;
/// <summary>
/// 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 <see cref="TiltedRemnantFiller"/>.
/// </summary>
public class TiltedRemnantStrategy : IFillStrategy
{
public string Name => "TiltedRemnant";
public NestPhase Phase => NestPhase.Custom;
public int Order => 500;
public List<Part> 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;
}
}
@@ -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]
+9
View File
@@ -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