feat(cutoffs): shift strip lines to shorten cuts

This commit is contained in:
aj committed 2026-10-13 19:29:32 -04:00
1 parent 15055aef4d
commit 5857e7fc69
4 files changed
+450 -6

No files matched your search

+96 -6
View File
@@ -21,6 +21,12 @@ public sealed class AutomaticCutOffOptions
/// The desktop default is 12 inches (304.8 mm).
/// </summary>
public double MinimumTailLength { get; set; }
/// <summary>
/// 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.
/// </summary>
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
/// <summary>True only for a verified full-width separator, never just a nominal boundary.</summary>
public bool HasSeparatedTail => TailSeparatorX.HasValue;
/// <summary>Total cut length of proposed new strip lines (excludes the tail separator).</summary>
public double StripCutLength { get; internal set; }
/// <summary>Length of nominal strip lines in the same gaps. Zero on the legacy path.</summary>
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;
/// <summary>
/// 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<AutomaticCutOffDiagnostic>();
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<double, (double Length, int SegmentCount)>();
var candidates = new HashSet<double>();
// 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<double>();
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 &&
+137
View File
@@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
namespace OpenNest;
/// <summary>Exact layered search over sorted positions; each transition uses a sliding minimum.</summary>
internal static class AutomaticCutOffStripSearch
{
internal static double[] FindPositions(double start, double end, int count, double spacing,
double minimum, double step, Func<double, bool> allowed,
Func<double, (double Length, int SegmentCount)> measure)
{
var rows = new List<double[]>(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<double>();
// 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<double[]> rows,
IReadOnlyList<double> nominal, double spacing, double minimum,
Func<double, (double Length, int SegmentCount)> measure)
{
var previous = new List<State> { new() { Position = start } };
for (var line = 0; line < rows.Count; line++)
{
var current = new List<State>(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<State> 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;
}
}
@@ -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<ArgumentException>(() => 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);
}
}
@@ -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<double[]>();
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);
}
}
}