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