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); + } + } +}