diff --git a/OpenNest.Core/AutomaticCutOffPlanner.cs b/OpenNest.Core/AutomaticCutOffPlanner.cs
index 818105f..6c40578 100644
--- a/OpenNest.Core/AutomaticCutOffPlanner.cs
+++ b/OpenNest.Core/AutomaticCutOffPlanner.cs
@@ -21,6 +21,12 @@ public sealed class AutomaticCutOffOptions
/// The desktop default is 12 inches (304.8 mm).
///
public double MinimumTailLength { get; set; }
+
+ ///
+ /// Minimum strip length in model units. Null or equal to Spacing preserves the fixed grid.
+ /// Otherwise must be finite, greater than MinimumSpacing and no greater than Spacing.
+ ///
+ public double? MinimumStripLength { get; set; }
}
public enum AutomaticCutOffDiagnosticCode
@@ -32,6 +38,7 @@ public enum AutomaticCutOffDiagnosticCode
ClampBlockedCut,
NoSafeTailSeparator,
TailBelowMinimum,
+ ShiftedCut,
}
public sealed record AutomaticCutOffDiagnostic(
@@ -70,6 +77,12 @@ public sealed class AutomaticCutOffPlan
/// True only for a verified full-width separator, never just a nominal boundary.
public bool HasSeparatedTail => TailSeparatorX.HasValue;
+ /// Total cut length of proposed new strip lines (excludes the tail separator).
+ public double StripCutLength { get; internal set; }
+
+ /// Length of nominal strip lines in the same gaps. Zero on the legacy path.
+ public double FixedGridCutLength { get; internal set; }
+
public bool HasBlockingDiagnostics => Diagnostics.Any(d => d.IsBlocking);
}
@@ -79,6 +92,7 @@ public static class AutomaticCutOffPlanner
public const double GeometryTolerance = Tolerance.Epsilon;
public const double MinimumSpacing = 2 * GeometryTolerance;
public const int MaximumCandidateCount = 10000;
+ public const int MaximumShiftCandidateCount = 20000;
///
/// Plans without changing the plate, its parts, or existing cut-off definitions/programs.
@@ -133,14 +147,25 @@ public static class AutomaticCutOffPlanner
var diagnostics = new List();
var separated = false;
var separatorX = (double?)null;
+ var shifted = options.MinimumStripLength.HasValue && options.MinimumStripLength.Value < options.Spacing
+ && !plate.CutOffs.Any(c => c.Axis == CutOffAxis.Vertical);
+ var stripCutLength = 0.0;
+ var fixedGridCutLength = 0.0;
+ var measurements = new Dictionary();
+ var candidates = new HashSet();
- // Multiplication by an integer avoids drift from repeated floating-point addition.
- for (var index = 1; index <= (int)candidateCount; index++)
+ if (shifted)
+ AddShiftedGap(0, usedSpan);
+ else
{
- var distance = index * options.Spacing;
- if (distance >= usedSpan - GeometryTolerance)
- break;
- AddCandidate(sign * distance, false);
+ // Keep the legacy loop and certification unchanged when shifting is disabled.
+ for (var index = 1; index <= (int)candidateCount; index++)
+ {
+ var distance = index * options.Spacing;
+ if (distance >= usedSpan - GeometryTolerance)
+ break;
+ AddCandidate(sign * distance, false);
+ }
}
if (hasTailCandidate)
{
@@ -158,6 +183,7 @@ public static class AutomaticCutOffPlanner
{
// A partial proposal must not accidentally be accepted around a manual conflict.
definitions.Clear();
+ stripCutLength = 0;
separated = false;
separatorX = null;
}
@@ -171,8 +197,64 @@ public static class AutomaticCutOffPlanner
UsedSpan = separated ? System.Math.Abs(separatorX.Value) : length,
TailLength = separated ? length - System.Math.Abs(separatorX.Value) : 0,
TailSeparatorX = separatorX,
+ StripCutLength = stripCutLength,
+ FixedGridCutLength = fixedGridCutLength,
};
+ void AddShiftedGap(double start, double end)
+ {
+ var nominal = new List();
+ for (var index = 1; index <= (int)candidateCount; index++)
+ {
+ var distance = start + index * options.Spacing;
+ if (distance >= end - GeometryTolerance)
+ break;
+ nominal.Add(distance);
+ fixedGridCutLength += Measure(distance).Length;
+ }
+ if (nominal.Count == 0)
+ return;
+ var step = System.Math.Max(options.Spacing / 700, usedSpan / MaximumShiftCandidateCount);
+ var positions = AutomaticCutOffStripSearch.FindPositions(start, end, nominal.Count,
+ options.Spacing, options.MinimumStripLength.Value, step, Allowed, Measure) ?? nominal.ToArray();
+ for (var index = 0; index < positions.Length; index++)
+ {
+ var distance = positions[index];
+ AddCandidate(sign * distance, false);
+ if (!Near(distance, nominal[index]))
+ diagnostics.Add(new AutomaticCutOffDiagnostic(AutomaticCutOffDiagnosticCode.ShiftedCut,
+ $"Moved from {nominal[index]:G8} to {distance:G8} model units; this line cuts "
+ + $"{Measure(nominal[index]).Length - Measure(distance).Length:G8} model units less.",
+ X: sign * distance));
+ }
+ }
+
+ bool Allowed(double distance)
+ {
+ RegisterCandidate(distance);
+ return true;
+ }
+
+ void RegisterCandidate(double distance)
+ {
+ if (candidates.Add(distance))
+ Require(candidates.Count <= MaximumShiftCandidateCount,
+ $"Shift search would generate more than {MaximumShiftCandidateCount} distinct positions. "
+ + "Increase spacing or the minimum strip length, or use the fixed grid.", nameof(options));
+ }
+
+ (double Length, int SegmentCount) Measure(double distance)
+ {
+ RegisterCandidate(distance);
+ if (!measurements.TryGetValue(distance, out var measured))
+ {
+ measured = new CutOff(new Vector(sign * distance, 0), CutOffAxis.Vertical)
+ .MeasureSegments(plate, settings, cache);
+ measurements.Add(distance, measured);
+ }
+ return measured;
+ }
+
void AddCandidate(double x, bool isSeparator)
{
var matches = plate.CutOffs.Where(c => c.Axis == CutOffAxis.Vertical &&
@@ -246,7 +328,11 @@ public static class AutomaticCutOffPlanner
X: x));
if (usable && existing == null)
+ {
definitions.Add(candidate);
+ if (shifted && !isSeparator)
+ stripCutLength += Measure(System.Math.Abs(x)).Length;
+ }
}
void WarnNoSeparator(double x) => diagnostics.Add(new AutomaticCutOffDiagnostic(
@@ -259,6 +345,10 @@ public static class AutomaticCutOffPlanner
{
Require(double.IsFinite(options.Spacing) && options.Spacing > MinimumSpacing,
$"Spacing must be finite and greater than {MinimumSpacing} model units.", nameof(options));
+ Require(!options.MinimumStripLength.HasValue ||
+ (double.IsFinite(options.MinimumStripLength.Value) && options.MinimumStripLength.Value > MinimumSpacing &&
+ options.MinimumStripLength.Value <= options.Spacing),
+ $"Minimum strip length must be finite, greater than {MinimumSpacing} and no greater than spacing.", nameof(options));
Require(Nonnegative(options.MinimumTailLength),
"Minimum tail length must be finite and nonnegative.", nameof(options));
Require(double.IsFinite(plate.Size.Length) && plate.Size.Length > 0 &&
diff --git a/OpenNest.Core/AutomaticCutOffStripSearch.cs b/OpenNest.Core/AutomaticCutOffStripSearch.cs
new file mode 100644
index 0000000..73a1e06
--- /dev/null
+++ b/OpenNest.Core/AutomaticCutOffStripSearch.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Collections.Generic;
+
+namespace OpenNest;
+
+/// Exact layered search over sorted positions; each transition uses a sliding minimum.
+internal static class AutomaticCutOffStripSearch
+{
+ internal static double[] FindPositions(double start, double end, int count, double spacing,
+ double minimum, double step, Func allowed,
+ Func measure)
+ {
+ var rows = new List(count);
+ var nominal = new double[count];
+ for (var line = 1; line <= count; line++)
+ {
+ var today = start + line * spacing;
+ nominal[line - 1] = today;
+ var low = System.Math.Max(start + line * minimum, end - (count - line + 1) * spacing);
+ var high = System.Math.Min(today, end - AutomaticCutOffPlanner.GeometryTolerance);
+ var positions = new List();
+ // Integer indices keep candidates on the origin-based lattice without cumulative drift.
+ var first = (long)System.Math.Ceiling(low / step);
+ var last = (long)System.Math.Floor(high / step);
+ for (var index = first; index <= last; index++)
+ {
+ var position = index * step;
+ if (position >= low && position <= high && allowed(position))
+ positions.Add(position);
+ }
+ if (allowed(today))
+ positions.Add(today);
+ positions.Sort();
+ for (var index = positions.Count - 1; index > 0; index--)
+ if (positions[index] == positions[index - 1])
+ positions.RemoveAt(index);
+ if (positions.Count == 0)
+ return null;
+ rows.Add(positions.ToArray());
+ }
+ return Solve(start, rows, nominal, spacing, minimum, measure);
+ }
+
+ // Kept independent of geometry so tests can compare against exhaustive, non-greedy oracles.
+ internal static double[] Solve(double start, IReadOnlyList rows,
+ IReadOnlyList nominal, double spacing, double minimum,
+ Func measure)
+ {
+ var previous = new List { new() { Position = start } };
+ for (var line = 0; line < rows.Count; line++)
+ {
+ var current = new List(rows[line].Length);
+ var deque = new int[previous.Count];
+ var head = 0;
+ var tail = 0;
+ var added = 0;
+ foreach (var position in rows[line])
+ {
+ var upper = position - minimum + AutomaticCutOffPlanner.GeometryTolerance;
+ var lower = position - spacing - AutomaticCutOffPlanner.GeometryTolerance;
+ while (added < previous.Count && previous[added].Position <= upper)
+ {
+ while (tail > head && Compare(previous[added], previous[deque[tail - 1]]) < 0)
+ tail--;
+ deque[tail++] = added++;
+ }
+ while (head < tail && previous[deque[head]].Position < lower)
+ head++;
+ if (head == tail)
+ continue;
+ var parent = previous[deque[head]];
+ var cost = measure(position);
+ current.Add(new State
+ {
+ Position = position,
+ Length = parent.Length + System.Math.Round(cost.Length, 6),
+ Pierces = parent.Pierces + cost.SegmentCount,
+ Deviation = parent.Deviation + System.Math.Abs(nominal[line] - position),
+ Parent = parent,
+ });
+ }
+ if (current.Count == 0)
+ return null;
+ AssignLexicographicRanks(current, previous.Count);
+ previous = current;
+ }
+ State best = null;
+ foreach (var state in previous)
+ if (best == null || Compare(state, best) < 0)
+ best = state;
+ var result = new double[rows.Count];
+ for (var line = result.Length - 1; line >= 0; line--)
+ {
+ result[line] = best.Position;
+ best = best.Parent;
+ }
+ return result;
+ }
+
+ private static int Compare(State left, State right)
+ {
+ var comparison = left.Length.CompareTo(right.Length);
+ if (comparison == 0)
+ comparison = left.Pierces.CompareTo(right.Pierces);
+ if (comparison == 0)
+ comparison = left.Deviation.CompareTo(right.Deviation);
+ return comparison != 0 ? comparison : left.LexicographicRank.CompareTo(right.LexicographicRank);
+ }
+
+ private static void AssignLexicographicRanks(List states, int parentCount)
+ {
+ // Stable counting sort by prefix rank. Positions are already sorted, so within a
+ // prefix their order is lexicographic. Ranking and transitions are linear per row.
+ var offsets = new int[parentCount];
+ foreach (var state in states)
+ offsets[state.Parent.LexicographicRank]++;
+ var total = 0;
+ for (var index = 0; index < offsets.Length; index++)
+ {
+ var count = offsets[index];
+ offsets[index] = total;
+ total += count;
+ }
+ foreach (var state in states)
+ state.LexicographicRank = offsets[state.Parent.LexicographicRank]++;
+ }
+
+ private sealed class State
+ {
+ public double Position;
+ public double Length;
+ public int Pierces;
+ public double Deviation;
+ public State Parent;
+ public int LexicographicRank;
+ }
+}
diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs
new file mode 100644
index 0000000..acd5a4a
--- /dev/null
+++ b/OpenNest.Tests/CutOffs/AutomaticCutOffShiftTests.cs
@@ -0,0 +1,159 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CutOffs;
+
+public class AutomaticCutOffShiftTests
+{
+ internal static AutomaticCutOffOptions Options(double scale = 1)
+ {
+ return new AutomaticCutOffOptions { Spacing = 35 * scale, MinimumStripLength = 17.5 * scale };
+ }
+
+ internal static Part Rectangle(double length, double width, double x, double y)
+ {
+ var program = new Program();
+ program.Codes.Add(new RapidMove(0, 0));
+ program.Codes.Add(new LinearMove(length, 0));
+ program.Codes.Add(new LinearMove(length, width));
+ program.Codes.Add(new LinearMove(0, width));
+ program.Codes.Add(new LinearMove(0, 0));
+ return new Part(new Drawing("shift rectangle", program), new Vector(x, y));
+ }
+
+ internal static Plate UserCase(int quadrant = 1, double scale = 1)
+ {
+ var plate = new Plate(60 * scale, 120 * scale) { Quadrant = quadrant, PartSpacing = 0.3 * scale };
+ foreach (var right in new[] { 34.75, 69.75, 80.0 })
+ plate.Parts.Add(Rectangle((right == 80 ? 5 : 10) * scale, (right == 80 ? 5 : 20) * scale,
+ (quadrant is 2 or 3 ? -right : right - (right == 80 ? 5 : 10)) * scale,
+ (quadrant is 3 or 4 ? -30 : 10) * scale));
+ return plate;
+ }
+
+ internal static double CutLength(Program program) => Enumerable.Range(0, program.Codes.Count / 2)
+ .Sum(i => System.Math.Abs(((LinearMove)program.Codes[2 * i + 1]).EndPoint.Y -
+ ((RapidMove)program.Codes[2 * i]).EndPoint.Y));
+
+ [Theory]
+ [InlineData(1, 1)]
+ [InlineData(2, 1)]
+ [InlineData(3, 1)]
+ [InlineData(4, 1)]
+ [InlineData(1, 25.4)]
+ [InlineData(2, 25.4)]
+ [InlineData(3, 25.4)]
+ [InlineData(4, 25.4)]
+ public void ParallelEdgeCase_SavesFortyInchesWithSameCountAndSeparator(int quadrant, double scale)
+ {
+ var plate = UserCase(quadrant, scale);
+ var settings = new CutOffSettings { PartClearance = 0.02 * scale };
+ var legacy = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 * scale }, settings);
+ var shifted = AutomaticCutOffPlanner.Create(plate, Options(scale), settings);
+ Assert.Equal(legacy.TailSeparatorX, shifted.TailSeparatorX);
+ Assert.Equal(3, shifted.Definitions.Count);
+ Assert.True(legacy.PreviewParts.Sum(p => CutLength(p.Program)) -
+ shifted.PreviewParts.Sum(p => CutLength(p.Program)) >= 39.9 * scale);
+ AssertLimits(shifted, 35 * scale, 17.5 * scale);
+ Assert.Empty(plate.CutOffs);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(0.00002)]
+ [InlineData(36)]
+ [InlineData(double.NaN)]
+ [InlineData(double.PositiveInfinity)]
+ public void MinimumStrip_RejectsInvalidValues(double minimum)
+ {
+ var options = Options();
+ options.MinimumStripLength = minimum;
+ Assert.Throws(() => AutomaticCutOffPlanner.Create(UserCase(), options, new CutOffSettings()));
+ }
+
+ [Fact]
+ public void EqualCosts_SelectNominalPositions_AndLastStripMayBeShort()
+ {
+ var plate = new Plate(60, 120) { PartSpacing = 0.3 };
+ plate.Parts.Add(Rectangle(40, 20, 0, 10));
+ var plan = AutomaticCutOffPlanner.Create(plate, Options(), new CutOffSettings());
+ Assert.Equal(new[] { 35.0, 40.3 + AutomaticCutOffPlanner.GeometryTolerance }, plan.Definitions.Select(c => c.Position.X));
+ Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ShiftedCut);
+ Assert.Equal(CutLength(plan.PreviewParts[0].Program), plan.StripCutLength, 8);
+ Assert.Equal(plan.StripCutLength, plan.FixedGridCutLength);
+ AssertLimits(plan, 35, 17.5);
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ public void NullAndEqualSpacing_AreIdenticalIncludingDiagnosticsAndTotals(int quadrant)
+ {
+ var plate = UserCase(quadrant);
+ plate.CutOffs.Add(new CutOff(new Vector(quadrant is 2 or 3 ? -35 : 35, 0), CutOffAxis.Vertical));
+ var options = new AutomaticCutOffOptions { Spacing = 35 };
+ var legacy = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
+ options.MinimumStripLength = 35;
+ var fixedGrid = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
+ Assert.Equal(legacy.Definitions.Select(c => (c.Position, c.Axis, c.StartLimit, c.EndLimit)),
+ fixedGrid.Definitions.Select(c => (c.Position, c.Axis, c.StartLimit, c.EndLimit)));
+ Assert.Equal(legacy.PreviewParts.Select(p => p.Program.ToString()), fixedGrid.PreviewParts.Select(p => p.Program.ToString()));
+ Assert.Equal(legacy.Diagnostics, fixedGrid.Diagnostics);
+ Assert.Equal(legacy.TailSeparatorX, fixedGrid.TailSeparatorX);
+ Assert.Equal(legacy.UsedSpan, fixedGrid.UsedSpan);
+ Assert.Equal(legacy.StripCutLength, fixedGrid.StripCutLength);
+ Assert.Equal(legacy.FixedGridCutLength, fixedGrid.FixedGridCutLength);
+ Assert.Equal(0, legacy.StripCutLength);
+ Assert.Equal(0, legacy.FixedGridCutLength);
+ }
+
+ [Fact]
+ public void SeededTwoHundredPlates_NeverLonger_RespectLimitsAndAreDeterministic()
+ {
+ var random = new Random(42131);
+ for (var sample = 0; sample < 200; sample++)
+ {
+ var quadrant = sample % 4 + 1;
+ var plate = new Plate(60, 120) { Quadrant = quadrant, PartSpacing = 0.3 };
+ for (var index = 0; index < 4; index++)
+ {
+ var right = 20 + random.NextDouble() * 90;
+ var width = 2 + random.NextDouble() * 8;
+ var height = 2 + random.NextDouble() * 12;
+ var top = 20 + random.NextDouble() * 20;
+ plate.Parts.Add(Rectangle(width, height, quadrant is 2 or 3 ? -right : right - width,
+ quadrant is 3 or 4 ? -top : top - height));
+ }
+ var options = Options();
+ var settings = new CutOffSettings();
+ var first = AutomaticCutOffPlanner.Create(plate, options, settings);
+ var second = AutomaticCutOffPlanner.Create(plate, options, settings);
+ var legacy = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings);
+ Assert.Equal(legacy.Definitions.Count, first.Definitions.Count);
+ Assert.Equal(legacy.TailSeparatorX, first.TailSeparatorX);
+ Assert.True(first.StripCutLength <= first.FixedGridCutLength + 0.000001);
+ Assert.Equal(first.Definitions.Select(c => c.Position.X), second.Definitions.Select(c => c.Position.X));
+ Assert.Equal(first.Diagnostics, second.Diagnostics);
+ Assert.Equal(first.StripCutLength, second.StripCutLength);
+ Assert.Equal(first.PreviewParts.Take(first.PreviewParts.Count - 1).Sum(p => CutLength(p.Program)), first.StripCutLength, 8);
+ AssertLimits(first, 35, 17.5);
+ }
+ }
+
+ internal static void AssertLimits(AutomaticCutOffPlan plan, double spacing, double minimum)
+ {
+ var positions = plan.Definitions.Where(c => c.Position.X != plan.TailSeparatorX)
+ .Select(c => System.Math.Abs(c.Position.X)).ToArray();
+ var previous = 0.0;
+ foreach (var position in positions)
+ {
+ Assert.InRange(position - previous, minimum - AutomaticCutOffPlanner.GeometryTolerance,
+ spacing + AutomaticCutOffPlanner.GeometryTolerance);
+ previous = position;
+ }
+ Assert.InRange(plan.UsedSpan - previous, 0, spacing + AutomaticCutOffPlanner.GeometryTolerance);
+ }
+}
diff --git a/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs b/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs
new file mode 100644
index 0000000..871a244
--- /dev/null
+++ b/OpenNest.Tests/CutOffs/AutomaticCutOffStripSearchTests.cs
@@ -0,0 +1,58 @@
+namespace OpenNest.Tests.CutOffs;
+
+public class AutomaticCutOffStripSearchTests
+{
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void EqualLength_PiercesThenDeviationBreakTies(bool pierceTieBreak)
+ {
+ var result = AutomaticCutOffStripSearch.Solve(0, new[] { new[] { 18.0, 35.0 } },
+ new[] { 35.0 }, 35, 17.5, x => (10, pierceTieBreak && x == 35 ? 2 : 1));
+ Assert.Equal(new[] { pierceTieBreak ? 18.0 : 35.0 }, result);
+ }
+
+ [Fact]
+ public void GlobalOptimum_IsBetterThanGreedyFirstLine()
+ {
+ var result = AutomaticCutOffStripSearch.Solve(0,
+ new[] { new[] { 20.0, 35.0 }, new[] { 40.0, 55.0, 70.0 } },
+ new[] { 35.0, 70.0 }, 35, 17.5, x => (x == 20 || x == 70 ? 0 : x == 35 ? 1 : 100, 1));
+ Assert.Equal(new[] { 35.0, 70.0 }, result);
+ }
+
+ [Fact]
+ public void LastTieBreak_IsLexicographicOverTheWholePrefix_NotTheLastPosition()
+ {
+ // Both paths are equally far from the nominal list, length and pierces are equal.
+ var result = AutomaticCutOffStripSearch.Solve(0,
+ new[] { new[] { 20.0, 25.0 }, new[] { 45.0, 50.0 } },
+ new[] { 22.5, 47.5 }, 35, 17.5, _ => (10, 1));
+ Assert.Equal(new[] { 20.0, 45.0 }, result);
+ }
+
+ [Fact]
+ public void SeededFiniteCandidateSearch_EqualsIndependentExhaustiveOracle()
+ {
+ var random = new Random(93512);
+ for (var sample = 0; sample < 200; sample++)
+ {
+ var rows = new[] { new[] { 18.0, 23, 29, 35 }, new[] { 38.0, 47, 56, 62, 70 }, new[] { 60.0, 74, 81, 91, 105 } };
+ var costs = rows.SelectMany(row => row).Distinct().ToDictionary(x => x,
+ _ => (Length: random.Next(0, 4) + random.Next(0, 2) * 0.0000001, SegmentCount: random.Next(0, 3)));
+ var nominal = new[] { 35.0, 70, 105 };
+ var paths = new List();
+ foreach (var first in rows[0])
+ foreach (var second in rows[1])
+ foreach (var third in rows[2])
+ if (second - first >= 17.5 && second - first <= 35 && third - second >= 17.5 && third - second <= 35)
+ paths.Add(new[] { first, second, third });
+ var expected = paths.OrderBy(path => path.Sum(x => System.Math.Round(costs[x].Length, 6)))
+ .ThenBy(path => path.Sum(x => costs[x].SegmentCount))
+ .ThenBy(path => Enumerable.Range(0, 3).Sum(i => System.Math.Abs(nominal[i] - path[i])))
+ .ThenBy(path => path[0]).ThenBy(path => path[1]).ThenBy(path => path[2]).First();
+ var actual = AutomaticCutOffStripSearch.Solve(0, rows, nominal, 35, 17.5, x => costs[x]);
+ Assert.Equal(expected, actual);
+ }
+ }
+}