From a0713289fa29cc2f6bc32047b2bd583928d2864c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 19 Oct 2026 19:50:29 -0400 Subject: [PATCH] feat(planning): tour large aligned grids as a serpentine A plate of at least 200 parts with no cutoffs or prerequisites, whose part centres bin into rows and columns within a quarter of the smallest part extent, is toured line by line in alternating directions. Each row and column gap must be wider than that tolerance, each cell may hold only one part, and at least a quarter of the cells must be filled (interleaved rows fill half). The route runs along whichever axis, from whichever corner, is shortest from the start point. This replaces the travelling-salesman 2-opt/Or-opt passes, which took about 8 s on 5,742 parts. It is still only a proposal: the joint search checks every rapid and lead, and replans after a learned rule use the general order. Smaller plates, and the characterization fixtures, keep their order unchanged. The washer nest's plate 1 now plans Ready in about 44 s (was 54 s), with 86k expansions against 91k. Co-Authored-By: Claude Opus 5.5 --- .../CuttingPlanning/CuttingGridOrder.cs | 126 ++++++++++++++++++ .../CuttingPlanning/JointCuttingPlanSearch.cs | 16 ++- .../CuttingPlanning/CuttingGridOrderTests.cs | 124 +++++++++++++++++ docs/cutting-planner.md | 9 +- 4 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 OpenNest.Engine/CuttingPlanning/CuttingGridOrder.cs create mode 100644 OpenNest.Tests/CuttingPlanning/CuttingGridOrderTests.cs diff --git a/OpenNest.Engine/CuttingPlanning/CuttingGridOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingGridOrder.cs new file mode 100644 index 00000000..fd198fb3 --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/CuttingGridOrder.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.CuttingPlanning; + +/// +/// Proposes a serpentine whole-part order for large plates whose parts sit in aligned rows and +/// columns: each row in turn, alternating direction, starting from the corner nearest the tool. +/// Part centres are binned into rows by Y and columns by X within a quarter of the smallest part +/// extent; a plate is a grid when every gap between neighbouring rows and columns is wider than +/// that, no two parts share a cell and the cells are at least a quarter occupied (interleaved +/// rows fill half). Rows run along whichever axis gives the shorter route. It replaces the +/// travelling-salesman improvement, whose passes grow with the square of the part count, and, +/// like that order, is only a proposal: the forward search still checks every rapid and lead. +/// +internal static class CuttingGridOrder +{ + /// Smaller plates keep the travelling-salesman order, which is fast enough there. + internal const int MinimumParts = 200; + + /// The bin tolerance as a fraction of the smallest part extent on that axis. + internal const double BinFraction = 0.25; + + /// The fewest grid cells, as a fraction, that must hold a part. + internal const double MinimumOccupancy = 0.25; + + /// Each part's cut extent, by ordinal. + /// The modeled tool position before the first part. + /// The serpentine order of every ordinal, or null when the parts do not form a grid. + internal static int[] Plan(IReadOnlyList extents, Vector start, CancellationToken token) + { + var count = extents.Count; + if (count < MinimumParts) + return null; + var centres = extents.Select(box => box.Center).ToArray(); + var rows = Bins(centres.Select(c => c.Y).ToArray(), + BinFraction * extents.Min(box => box.Top - box.Bottom), token); + var columns = Bins(centres.Select(c => c.X).ToArray(), + BinFraction * extents.Min(box => box.Right - box.Left), token); + if (rows == null || columns == null) + return null; + var rowCount = rows.Max() + 1; + var columnCount = columns.Max() + 1; + if (rowCount < 2 || columnCount < 2 || count < MinimumOccupancy * rowCount * (double)columnCount) + return null; + var cells = new HashSet<(int, int)>(); + for (var part = 0; part < count; part++) + if (!cells.Add((rows[part], columns[part]))) + return null; + + token.ThrowIfCancellationRequested(); + int[] best = null; + var bestLength = double.PositiveInfinity; + foreach (var (lines, along) in new[] { (rows, columns), (columns, rows) }) + foreach (var reverseLines in new[] { false, true }) + foreach (var reverseFirst in new[] { false, true }) + { + var order = Serpentine(lines, along, reverseLines, reverseFirst); + var length = Length(order, centres, start); + if (length < bestLength) + { + best = order; + bestLength = length; + } + } + return best; + } + + /// + /// Bin index per value: sorted values open a new bin once they pass the bin's first value by + /// more than . Null when two bins are not separated by more than + /// the tolerance, since the values then drift rather than line up. + /// + internal static int[] Bins(IReadOnlyList values, double tolerance, CancellationToken token) + { + if (!double.IsFinite(tolerance) || tolerance <= Tolerance.Epsilon || values.Any(v => !double.IsFinite(v))) + return null; + var sorted = Enumerable.Range(0, values.Count).OrderBy(i => values[i]).ToArray(); + var bins = new int[values.Count]; + var bin = 0; + var first = values[sorted[0]]; + var previous = first; + foreach (var index in sorted) + { + token.ThrowIfCancellationRequested(); + var value = values[index]; + if (value - first > tolerance) + { + if (value - previous <= tolerance) + return null; + bin++; + first = value; + } + bins[index] = bin; + previous = value; + } + return bins; + } + + // Lines in bin order (or reversed), each sorted along the other axis, alternating direction. + private static int[] Serpentine(int[] lines, int[] along, bool reverseLines, bool reverseFirst) + { + var groups = Enumerable.Range(0, lines.Length).GroupBy(part => lines[part]) + .OrderBy(group => reverseLines ? -group.Key : group.Key).ToArray(); + var order = new List(lines.Length); + for (var line = 0; line < groups.Length; line++) + { + var forward = (line % 2 == 0) != reverseFirst; + order.AddRange(forward ? groups[line].OrderBy(part => along[part]) + : groups[line].OrderByDescending(part => along[part])); + } + return order.ToArray(); + } + + private static double Length(int[] order, Vector[] centres, Vector start) + { + var length = start.DistanceTo(centres[order[0]]); + for (var i = 1; i < order.Length; i++) + length += centres[order[i - 1]].DistanceTo(centres[order[i]]); + return length; + } +} diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs index 278e31a5..1e8757d0 100644 --- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs +++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs @@ -60,7 +60,13 @@ internal static class JointCuttingPlanSearch .Select(i => new HashSet(snapshot.Dependencies.PrerequisitesOf(i))).ToArray(); var nesting = NestedOrder(snapshot, centres, prerequisites, token); - var sequence = CuttingNestedOrder.Plan(Enumerable.Range(0, count).ToArray(), snapshot.StartPoint, nesting); + // A large aligned grid with no required order is toured row by row; anything else, + // and every replan after a learned rule, gets the general order. + var extents = snapshot.Placements.Select(CutExtent).ToArray(); + var sequence = (prerequisites.All(p => p.Count == 0) && extents.All(e => e != null) + && !snapshot.Placements.Any(p => p.IsCutOff) + ? CuttingGridOrder.Plan(extents, snapshot.StartPoint, token) : null) + ?? CuttingNestedOrder.Plan(Enumerable.Range(0, count).ToArray(), snapshot.StartPoint, nesting); Node resume = null; while (true) { @@ -167,12 +173,16 @@ internal static class JointCuttingPlanSearch } // The centre of the part's placed cut material, for ordering only. - private static Vector Centre(FixedProgramPlacement placement) + private static Vector Centre(FixedProgramPlacement placement) => + CutExtent(placement)?.Center ?? placement.Execution.DeparturePoint; + + // The extent of the part's placed cut material, or null when it has none. + private static Box CutExtent(FixedProgramPlacement placement) { var cuts = placement.Execution.Motions .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null) .Select(m => m.Curve.ToEntity().BoundingBox).ToList(); - return cuts.Count == 0 ? placement.Execution.DeparturePoint : cuts.GetBoundingBox().Center; + return cuts.Count == 0 ? null : cuts.GetBoundingBox(); } internal static CuttingPlanFinding Finding(FixedProgramPlacement source, PostVerificationKind? kind, string message) => diff --git a/OpenNest.Tests/CuttingPlanning/CuttingGridOrderTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingGridOrderTests.cs new file mode 100644 index 00000000..eb24d4cb --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/CuttingGridOrderTests.cs @@ -0,0 +1,124 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +/// Large plates of parts in aligned rows and columns are toured as a serpentine. +public class CuttingGridOrderTests +{ + private const double Diameter = 0.94; + + [Fact] + public void InterleavedGrid_IsToured_LineByLine_AlternatingDirection() + { + var extents = Interleaved(12, 20).Select(Extent).ToArray(); + + var order = CuttingGridOrder.Plan(extents, Vector.Zero, default); + + Assert.NotNull(order); + Assert.Equal(Enumerable.Range(0, extents.Length), order.Order()); + // Columns hold every other row 1.2 apart, rows every other column 2.06 apart: the route + // runs up and down the columns, from the corner nearest the start. + var x = order.Select(i => extents[i].Center.X).ToArray(); + var y = order.Select(i => extents[i].Center.Y).ToArray(); + Assert.Equal(0, order[0]); + var line = 0; + var direction = 1; + for (var i = 1; i < order.Length; i++) + { + if (System.Math.Abs(x[i] - x[i - 1]) < 1e-9) + { + Assert.Equal(direction, System.Math.Sign(y[i] - y[i - 1])); + continue; + } + Assert.True(x[i] > x[i - 1], $"Column {line} is left before it is finished."); + line++; + direction = -direction; + } + Assert.Equal(23, line); + } + + [Fact] + public void SmallPlate_KeepsTheGeneralOrder() + { + var extents = Interleaved(6, 10).Select(Extent).ToArray(); + + Assert.True(extents.Length < CuttingGridOrder.MinimumParts); + Assert.Null(CuttingGridOrder.Plan(extents, Vector.Zero, default)); + } + + [Fact] + public void ScatteredParts_AreNotAGrid() + { + var random = new Random(3); + var extents = Enumerable.Range(0, 300) + .Select(_ => Extent(new Vector(random.NextDouble() * 100, random.NextDouble() * 100))).ToArray(); + + Assert.Null(CuttingGridOrder.Plan(extents, Vector.Zero, default)); + } + + [Fact] + public void DriftingRows_AreNotAGrid() + { + // The rows slope upward by 0.1 per unit of X: no gap separates one row from the next. + var extents = Interleaved(12, 20).Select(centre => Extent(centre + new Vector(0, centre.X * 0.1))).ToArray(); + + Assert.Null(CuttingGridOrder.Plan(extents, Vector.Zero, default)); + } + + [Fact] + public void TwoPartsInOneCell_AreNotAGrid() + { + var centres = Interleaved(12, 20).ToList(); + centres[5] = centres[4] + new Vector(0.1, 0.1); + + Assert.Null(CuttingGridOrder.Plan(centres.Select(Extent).ToArray(), Vector.Zero, default)); + } + + [Fact] + public void WasherGrid_PlansReady_AlongTheSerpentine() + { + var nest = new Nest(); + var plate = nest.CreatePlate(); + plate.Size = new Size(30, 30); + var drawing = new Drawing("washer", Washer()); + foreach (var centre in Interleaved(12, 20)) + plate.Parts.Add(new Part(drawing, centre - new Vector(Diameter / 2, Diameter / 2))); + var parameters = new CuttingParameters + { + ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }, + InternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }, + ArcCircleLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 90 }, + }; + + var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate, + expansionBudget: CuttingPlanBatch.PlateBudget(plate.Parts.Count), confirmedParameters: parameters)); + + Assert.True(result.Status == CuttingPlanStatus.Ready, + $"{result.Status}: " + string.Join("; ", result.Findings.Take(3).Select(f => f.Message))); + Assert.True(result.IndependentlyReplayed); + var expected = CuttingGridOrder.Plan(plate.Parts.Select(p => p.BoundingBox).ToArray(), Vector.Zero, default); + Assert.Equal(expected, result.ProposedOrder.Select(p => p.SourceOrdinal)); + } + + // Rows 0.6 apart, each holding every other column of a 1.03 pitch, alternate rows offset by + // one column: the interleaved layout of round parts nested as tightly as they go. + private static IEnumerable Interleaved(int perRow, int rows) => + Enumerable.Range(0, rows).SelectMany(row => Enumerable.Range(0, perRow) + .Select(column => new Vector(1 + (2 * column + row % 2) * 1.03, 1 + row * 0.6))); + + private static Box Extent(Vector centre) => + new(centre.X - Diameter / 2, centre.Y - Diameter / 2, Diameter, Diameter); + + private static Program Washer() + { + var p = new Program(); + p.MoveTo(Diameter, Diameter / 2); + p.ArcTo(Diameter, Diameter / 2, Diameter / 2, Diameter / 2, RotationType.CCW); + p.MoveTo(Diameter / 2 + 0.2, Diameter / 2); + p.ArcTo(Diameter / 2 + 0.2, Diameter / 2, Diameter / 2, Diameter / 2, RotationType.CCW); + return p; + } +} diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index c0d95a5e..8842eedd 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -176,7 +176,14 @@ the run. Host groups follow the parts beside them, because a finished host is a obstacle that can only be left across its own outside edge. Learned rules win over both: a rule between parts of two groups orders the groups, and rules that cannot keep a group whole fall back to the plain tour. Plates without nested parts get the -plain tour unchanged. If a part on that order cannot be +plain tour unchanged, except a large grid: a plate of at least 200 parts with no +cutoffs or prerequisites, whose part centres bin into rows by Y and columns by X +within a quarter of the smallest part extent (every gap between neighbouring bins +wider than that, no two parts in one cell, at least a quarter of the cells filled, +as interleaved rows fill half), is toured as a serpentine: line by line, alternating +direction, along whichever axis and from whichever corner gives the shortest route +from the start point. Its 2-opt and Or-opt passes would grow with the square of the +part count. Replans after a learned rule use the plain tour. If a part on that order cannot be reached without crossing parts already cut, the search learns "cut this part before those", backs up to just before the earliest of them and re-plans the rest from the tool position there; parts cut before that point are kept. The first pass